Morph
Morph one stroked icon into another without the spikes.
npm install @cesarvenzac/morph// Shuffle never stops: each time the morph lands on its second icon, it goes on
// from there to another, at random, instead of turning back. The frame it landed
// on is the next morph's first, exactly, so there's no seam.
const clock = useClock(moving, {
onLand: () => {
if (!shuffle) return false; // there and back
setFrom(to);
setTo(anotherThan(to, from));
return true; // from the start of the new pair
},
});
const m = useMemo(() => morph(ICONS[from], ICONS[to], "soft"), [from, to]);
<Strokes strokes={m.at(clock.t)} />;
Use
import { morph } from "@cesarvenzac/morph";
import { Plus, X } from "lucide";
const m = morph(Plus, X); // the matching happens once, here
m.at(0.5); // [{ d: "M4.88 14.95C…", width: 1 }, …]: one path per strokeat(t) draws any moment from 0 (the first icon) to 1 (the second), each stroke as SVG path data. Drive t however you animate: a spring, a tween, a scroll position, a slider. In React:
import { morph, type Icon } from "@cesarvenzac/morph";
import { useMemo } from "react";
export function MorphIcon({ from, to, t }: { from: Icon; to: Icon; t: number }) {
const m = useMemo(() => morph(from, to), [from, to]);
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{m.at(t).map((stroke, k) => (
<path key={k} d={stroke.d} strokeWidth={2 * stroke.width} />
))}
</svg>
);
}Icons
An icon is any of:
- SVG path data:
"M5 12h14M12 5v14". - A list of it, one entry a stroke or more:
["M5 12h14", "M12 5v14"]. - Elements as Lucide writes its icons,
[tag, attributes][]: anything from thelucidepackage as it is.path,line,polyline,polygon,circle,ellipseandrectare read.
It morphs strokes, not filled shapes: icons drawn with fill="none" and a stroke, as Lucide's and Feather's are. Its distances are tuned for a 24-unit grid, so scale other icons to 24 first.
API
morph(from, to, way?)
way is "soft" (the default), "even" (evenly resampled points moved in straight lines, as flubber does), "index" (commands paired in the order they were written, as most libraries do), or Steps. It returns a morph:
slots: how many strokes every frame has. A stroke one icon has and the other doesn't gathers into a dot and shrinks away, or grows from one.at(t): the frame attas{ d, width }[],widtha share of the icon's stroke width. At0and1, exactly the two icons.shapes(t): the same frame as anchors and handles, as an editor shows a path.atis drawn from them, so the two always agree.points(t): the frame's points before they're drawn as curves.
Steps
Soft is even with four steps on, each switchable alone, to show what each one does:
import { EVENLY, morph } from "@cesarvenzac/morph";
morph(Plus, X, { ...EVENLY, shape: true }); // even, but paired by shapeshape: pair points by the shape they make, not by how far they travel.whole: move each stroke whole (its place, size and turn), apart from its shape.round: blur each stroke along its length, most halfway, so a corner arrives rounded.curves: draw fitted cubics rather than a polygon, sharp only at an end's own corners.
SOFTLY is all four on, EVENLY none, and nodesData(nodes, closed) turns a shape's nodes back into path data.
Cost
Building a morph is the costly part, about 15 ms for a pair of Lucide icons, so keep the morph, not the icons; a frame is then about 0.2 ms. How it works is in Morphs without spikes, and the three ways side by side in Icon morphs. The icons are Lucide's.