A driver is the thing that changes the shape over time. The compiled tier handed that job
to CSS and gave up interruption to get it. This one keeps the job, in 1.90 kB of
JavaScript, and works in every engine because it writes the d attribute directly instead
of relying on a CSS property that not every browser has.
Two names appear in the snippet below, and both are less than they sound.
A bundle is the compiled output from earlier in the book: a plain object holding, for
each pair of icons, the list of d strings to walk between. coreBundle is simply this
site's own, written to lib/morphs.generated.ts by the build. Nothing computes geometry at
runtime, because it was all worked out before the page shipped.
createMorph attaches a driver to some elements and hands you back four methods.
morphTosetseekdestroyimport { createMorph } from "@/lib/morph/runtime";
import { coreBundle } from "@/lib/morphs.generated";
export function mount(paths: readonly SVGPathElement[]) {
const morph = createMorph(paths, coreBundle, "menu");
// Uncontrolled: animate toward an icon. Interruptible, and a reversal re-bases in place.
morph.morphTo("cross", "snappy");
// Jump with no animation, landing on the canonical resting geometry.
morph.set("menu");
// Frozen at a progress you own, which is the primitive for scrubbing and gestures.
morph.seek("menu", "cross", 0.4);
return () => morph.destroy();
}The first argument is a list of <path> elements, one per slot. createMorph asks nothing
of them except setAttribute, which is structural on purpose: the same driver will back a
React Native binding through react-native-svg without a rewrite.
import { createMorph } from "@/lib/morph/runtime";
// A preset, or a duration in seconds. Only the duration varies: the curve is fixed, and
// a partial move scales down from it rather than running the full time.
declare const morph: ReturnType<typeof createMorph>;
morph.morphTo("cross", "smooth");
morph.morphTo("cross", { duration: 0.25 });Three presets, and they differ in duration rather than in character: smooth is 520ms,
snappy is 340ms and the default, and bouncy is 340ms with the icon overshooting its
size as the shape lands. Or pass your own duration and bounce.
bouncy bounces the icon rather than the shape, and there is no spring on either. That
is a property of morphing rather than a preference. Progress indexes a track between two
authored shapes, so there is nothing past the far end to travel to, and the driver clamps
into 0..1 before sampling. Extrapolating cubic control points would invent geometry nobody
authored.
So the overshoot has to live somewhere that has room for it. bouncy is a damped sine on
the icon's scale, windowed to start at 55% of the shape's travel so it peaks as the geometry
arrives rather than while it is still moving, where it would read as the icon breathing.
So an underdamped spring cannot show its overshoot, only spend frames on it. Worse, a spring with the overshoot removed is just exponential decay: measured, every preset covered half its distance in the first 22% of the animation, then spent half the total duration on the final 10%. Snap, then dribble. That is what made the motion feel forced.
The curve is symmetric instead. Half the distance takes half the time, and the last 10% of distance takes 29% of the duration rather than 50%.
An interruption takes a decelerating curve rather than the symmetric one, because the shape is already moving and accelerating from a standstill at the exact moment of a reversal is the hitch this was meant to remove. Its duration also scales with the distance left, so reversing at 95% finishes quickly instead of taking a full move to cross a sliver.
A morph is a baked track from A to B, so progress 0 is A and 1 is B. Asking to go back to A mid-flight is not a new animation needing a re-plan, it is the same tween aiming at 0 with its velocity intact.
That is a property of knowing the pair is a reversal. A general solver cannot know that, which is why it has to rebuild its plan from whatever is on screen.
It does not solve, align, parse or sample. All of that happened at build time, and the bundle it consumes is flat numbers. The per-frame work is a lerp and one string build.
Keeping it that way is deliberate: if the driver re-derived alignment for an interrupt, the whole Procrustes solver would land in every consumer's bundle.