stroke_morph
Universal morphing for stroke-based icons, with spring physics. Any stroke icon morphs into any other — and the rotations, scalings and folds are not authored, they fall out of the math.
An unofficial Dart/Flutter port of morphicons by Guillermo (source). The algorithm and the test invariants are that project's work; this package reimplements them in Dart and adds the Flutter rendering layer. Not affiliated with or endorsed by it. Working in TypeScript? Use the original.
The port mirrors the reference implementation's core module for module and is pinned to it by a cross-language parity suite.
import 'package:stroke_morph_icons/lucide.dart';
MorphIcon(icon: open ? MorphLucideIcons.x : MorphLucideIcons.menu, size: 32)
That is the whole API for the common case: change the icon, and the hamburger folds into a cross at ±45° because that is the shortest way for three horizontal strokes to become two diagonals.
→ Full usage guide — install, the three modes, springs, accessibility, recipes, the lower-level controller, and the gotchas worth knowing before you hit them.
Install
dependencies:
stroke_morph_icons: ^0.1.0 # icons AND the engine — one line is enough
stroke_morph_icons re-exports
this package, so one dependency and one import give you both the 7 210 icon
constants and MorphIcon. Using your own SVGs or d strings instead? Then you
only need the engine:
dependencies:
stroke_morph: ^0.1.0 # engine only, no icon data
No runtime dependencies beyond Flutter itself. See the usage guide for the git-dependency form.
Icon data
Anything shaped like an SVG stroke icon works — the format is the contract, not a particular pack:
// A raw `d` attribute.
const menu = PathIcon('M4 6h16M4 12h16M4 18h16');
// Lucide-style `[tag, attrs]` data: path, line, circle, ellipse, rect,
// polyline and polygon.
const dot = NodeIcon([IconEl('circle', {'cx': 12, 'cy': 12, 'r': 4})]);
Prefer const: Dart canonicalizes const objects, so the same icon referenced
from two places is identical, which is exactly what the driver's caches and
change detection key on.
Icons must share a coordinate space. Lucide and Tabler already draw on 24×24; for a pack on another grid, re-grid it once at module scope:
final heroicon = PathIcon(fitIcon(const PathIcon(rawD), 20));
The three modes
Uncontrolled — change icon, the spring does the rest. Interruptible: a
change mid-flight re-plans from the shape currently on screen and carries the
velocity across.
MorphIcon(icon: playing ? MorphLucideIcons.pause : MorphLucideIcons.play, preset: SpringPreset.bouncy)
Controlled — from, to and progress own the path and nothing animates
on its own. Drive it from a gesture, a scroll offset, or an Animation.
MorphIcon(from: MorphLucideIcons.menu, to: MorphLucideIcons.x, progress: drawerFraction)
Imperative — hold a GlobalKey<MorphIconState>.
final key = GlobalKey<MorphIconState>();
// …
key.currentState!.morphTo(MorphLucideIcons.check);
Props
| Prop | Default | |
|---|---|---|
icon |
— | uncontrolled mode: the current icon |
from / to / progress |
— | controlled mode: the frozen pair |
preset |
SpringPreset.snappy |
smooth, snappy or bouncy |
stiffness / damping |
from preset | override either component |
duration |
from preset | how long the morph takes; Duration.zero disables the animation |
size |
24 |
logical pixels; icons are drawn on a 24×24 grid |
color |
ambient IconTheme |
the analogue of SVG currentColor |
strokeWidth |
2 |
in icon space |
absoluteStrokeWidth |
false |
keep the on-screen width constant across sizes |
label |
— | with it the icon is an image to screen readers; without it, hidden |
A duration keeps the spring but sets its speed: preset, stiffness and
damping then only decide the damping ratio, so preset: bouncy, duration: 900ms is the same bounce stretched to 900 ms. See
doc/USAGE.md.
Reduced motion is honoured automatically: with
MediaQuery.disableAnimationsOf(context) on, morphTo degrades to an instant
set and no frames are scheduled.
Lifecycle contract
Shared verbatim with the original project's react, react-native, vue and svelte bindings, and pinned by mirrored test suites in each:
- Lazy driver. Mounting with no icon is fine — it paints nothing, and the
first icon to arrive (a late prop, a late pair, or an imperative call) brings
the driver to life without animating.
morphTobefore there is anything to fly from behaves asset. - Controlled wins. While
fromandtoare both present, the pair owns the path andiconchanges are ignored. Drop the pair andicontakes over, animated. - Clean re-entry. Any exit from controlled mode invalidates the frozen
pair, so returning to it re-bases on
fromand renders exactly like a fresh mount at thatprogress.
Below the widget
MorphController is the driver, usable on its own — it is a ChangeNotifier
that owns a ui.Path:
final morph = MorphController(vsync: this, icon: MorphLucideIcons.menu);
morph.morphTo(MorphLucideIcons.x); // spring, interruptible
morph.set(MorphLucideIcons.check); // instant
morph.seek(MorphLucideIcons.check, 0.5); // frozen at t, no ticker
morph.progress = 0.25; // ≡ seek on the active target
CustomPaint(painter: MorphPainter(controller: morph, color: c, strokeWidth: 2));
And the pure core is importable without any of the above: resampleIcon,
buildPlan, interpPolar, Spring, fitIcon, serialize.
What this port does differently from the web
Two deliberate divergences, both because Flutter is a better host for this than a DOM:
- No
dstring per frame. The web driver must serialize to a string that the browser re-parses; here the interpolated buffers feedui.Pathdirectly.MorphController.toPathD()still exposes the string for interop. - Curve-exact at rest. The web quantizes the resting shape to 4 decimals so SSR bytes match after hydration. Flutter has no hydration step, so the canonical snap emits real cubics.
There is also no hand-rolled frame scheduler: Flutter's SchedulerBinding
already multiplexes every Ticker onto one frame callback. A settled icon holds
no ticker at all.
Parity with the reference implementation
The ported suites cover the same invariants as the originals — emergent rotations, endpoint exactness to 1e-9, anchored corners, surjective matching, intrinsic sampling of closed paths, block-transport rigidity — because those assertions are computed properties, not recorded outputs, and so are language-independent.
On top of that, test/parity_test.dart replays vectors dumped from the real
TypeScript pipeline and fails if the two disagree: plan fields, interpolated
frames at t = 0 / 0.25 / 0.5 / 0.75 / 1 / 1.15, and byte-identical serialized
output, over 28 icons and 26 pairs.
The vectors are checked in (test/fixtures/parity-vectors.json), so
flutter test needs nothing extra. Regenerating them requires a checkout of the
upstream TypeScript project — see tool/dump-vectors.ts for how.
Development
flutter test # core, controller, widget and parity suites
dart analyze --fatal-infos
dart run tool/contact_sheet.dart # every key pair at every stop, as HTML
cd example && flutter run # the interactive playground
A morph's quality is validated by eye, not only by asserts. The example app has a scrubber that freezes exactly t = 0.25 / 0.5 / 0.75; the contact sheet is the same thing rendered to a file, for review without a display.
Licence
MIT, preserving the original morphicons copyright alongside the one for this port. See LICENSE.
Libraries
- stroke_morph
- Universal morphing for stroke-based icons with spring physics.