Morph Icon
IconsTrue SVG path interpolation between two icons — not a cross-fade. Ships its own path normalizer with no dependency: a cursor-based parser (correct on packed arc flags, where a number regex silently fails), every command reduced to cubics including the full endpoint→centre arc conversion, equal-arc-length resampling, and a rotation/direction search that minimises total travel so the morph never ties itself in a knot. Multiple subpaths are paired by centroid, and an unmatched one collapses into its nearest counterpart.
Scrub slowly through the middle — no crossings, no knots.
Click repeatedly — it reverses cleanly from wherever it is.
3 subpaths → 2 · the middle line collapses into the X
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* MorphIcon — true SVG path interpolation between two icons.
*
* Not a cross-fade. Two arbitrary `d` strings have different command counts and
* different command types, so they cannot be lerped directly; this file builds
* the normalizer that makes them lerpable, with no dependency.
*
* The pipeline, run **once per icon pair** (memoised on `from`/`to`/`samples`),
* never per frame:
*
* 1. **Parse** to absolute commands. A cursor-based scanner, not a global
* number regex, because arc flags are allowed to be packed without
* separators — `a1 1 0 015 5` is legal and means flags `0`,`1` then `5`.
* A naive `/-?[\d.]+/g` reads that as the single number `015` and silently
* produces a wrong shape.
* 2. **Reduce to one command type.** `H`/`V`/`L` become degenerate cubics,
* `Q`/`T` lift to cubics exactly (control points at 2/3), and `A` goes
* through the full endpoint→centre parameterisation of SVG spec F.6.5,
* split at 90° and approximated per arc with `alpha = 4/3·tan(Δθ/4)`.
* After this everything is `M` + cubics (+ optional close).
* 3. **Resample** each subpath to an equal point count at equal arc length,
* by flattening the cubics and walking the cumulative-length table.
* Closed subpaths sample `i/n` (wrapping); open ones sample `i/(n-1)` so
* both endpoints land exactly on the tips.
* 4. **Rotate the point ordering** to minimise total travel. This is the step
* people skip, and it is the whole difference between a morph that looks
* intentional and one that ties itself in a knot. For closed subpaths all
* `n` rotations are scored; for open ones rotation would move the
* endpoints, so only the direction is chosen. Both cases also score the
* **reversed** traversal, which is what fixes a clockwise shape morphing
* into a counter-clockwise one — no separate winding-normalisation pass
* is needed, the search subsumes it.
*
* ## Multiple subpaths
*
* Fully supported — `menu` (three lines) → `close` (two lines) is a 3→2 pairing
* and one of the three named test cases, so scoping v1 to single subpaths would
* have failed the first icon. Subpaths are sorted into a canonical order by
* centroid and paired by index; when the counts differ, each unpaired subpath
* is matched against a **degenerate copy of itself collapsed onto the nearest
* counterpart's centroid**, so the extra line shrinks into the shape it is
* joining instead of vanishing.
*
* ## Two deliberate limits
*
* - The morph renders as a polyline of `samples` points, so mid-flight a sharp
* corner is rounded by at most half the sample spacing (sub-pixel at icon
* size). At rest it is exact: `t ≤ 0.0005` and `t ≥ 0.9995` return the
* original `d` strings verbatim, so the idle icon is never an approximation.
* - A pair is closed only when **both** sides are closed. Morphing an open
* stroke into a filled shape has no correct answer; the open reading is the
* safe one for icon sets, which are overwhelmingly strokes.
*/
type Pt = [number, number];
/** [control1, control2, end] — the start point is the previous end. */
type Cubic = [Pt, Pt, Pt];
interface Subpath {
start: Pt;
cubics: Cubic[];
closed: boolean;
}
interface Sampled {
pts: Pt[];
closed: boolean;
}
// ---------------------------------------------------------------------------
// 1 · Parsing
// ---------------------------------------------------------------------------
/**
* A cursor over a path's parameter list. Needed instead of a number regex
* because the arc command's two flags may be written as bare digits with no
* separator, so position — not pattern — decides how to read them.
*/
class Cursor {
private i = 0;
private readonly s: string;
constructor(s: string) {
this.s = s;
}
private skip() {
while (this.i < this.s.length) {
const c = this.s[this.i];
if (c === " " || c === "," || c === "\n" || c === "\r" || c === "\t") {
this.i++;
} else {
break;
}
}
}
done(): boolean {
this.skip();
return this.i >= this.s.length;
}
number(): number {
this.skip();
const start = this.i;
if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
if (this.s[this.i] === ".") {
this.i++;
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
}
if (this.s[this.i] === "e" || this.s[this.i] === "E") {
const mark = this.i;
this.i++;
if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
if (this.s[this.i] >= "0" && this.s[this.i] <= "9") {
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
} else {
this.i = mark;
}
}
const n = Number(this.s.slice(start, this.i));
return Number.isFinite(n) ? n : 0;
}
/** An arc flag: a single `0` or `1`, which may be glued to the next number. */
flag(): number {
this.skip();
const c = this.s[this.i];
if (c === "0" || c === "1") {
this.i++;
return c === "1" ? 1 : 0;
}
return this.number() ? 1 : 0;
}
}
/**
* Endpoint → centre parameterisation (SVG spec F.6.5), then a cubic per ≤90°
* of sweep. Anything less than the real conversion visibly distorts a circle.
*/
function arcToCubics(
x1: number,
y1: number,
rxIn: number,
ryIn: number,
phiDeg: number,
fA: number,
fS: number,
x2: number,
y2: number,
): Cubic[] {
if (rxIn === 0 || ryIn === 0 || (x1 === x2 && y1 === y2)) {
return [
[
[x1, y1],
[x2, y2],
[x2, y2],
],
];
}
let rx = Math.abs(rxIn);
let ry = Math.abs(ryIn);
const phi = (phiDeg * Math.PI) / 180;
const cosP = Math.cos(phi);
const sinP = Math.sin(phi);
const dx = (x1 - x2) / 2;
const dy = (y1 - y2) / 2;
const x1p = cosP * dx + sinP * dy;
const y1p = -sinP * dx + cosP * dy;
// F.6.6 — scale the radii up if they can't span the endpoints.
const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if (lambda > 1) {
const s = Math.sqrt(lambda);
rx *= s;
ry *= s;
}
const sign = fA === fS ? -1 : 1;
const numer = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
const denom = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
const co = sign * Math.sqrt(Math.max(0, numer / denom));
const cxp = (co * (rx * y1p)) / ry;
const cyp = (co * (-ry * x1p)) / rx;
const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
const angle = (ux: number, uy: number, vx: number, vy: number) => {
const dot = ux * vx + uy * vy;
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
let a = Math.acos(Math.min(1, Math.max(-1, len === 0 ? 1 : dot / len)));
if (ux * vy - uy * vx < 0) a = -a;
return a;
};
const ux = (x1p - cxp) / rx;
const uy = (y1p - cyp) / ry;
const vx = (-x1p - cxp) / rx;
const vy = (-y1p - cyp) / ry;
const theta1 = angle(1, 0, ux, uy);
let dTheta = angle(ux, uy, vx, vy);
if (fS === 0 && dTheta > 0) dTheta -= 2 * Math.PI;
if (fS === 1 && dTheta < 0) dTheta += 2 * Math.PI;
const count = Math.max(1, Math.ceil(Math.abs(dTheta) / (Math.PI / 2)));
const delta = dTheta / count;
const alpha = (4 / 3) * Math.tan(delta / 4);
const point = (t: number): Pt => [
cx + rx * Math.cos(t) * cosP - ry * Math.sin(t) * sinP,
cy + rx * Math.cos(t) * sinP + ry * Math.sin(t) * cosP,
];
const deriv = (t: number): Pt => [
-rx * Math.sin(t) * cosP - ry * Math.cos(t) * sinP,
-rx * Math.sin(t) * sinP + ry * Math.cos(t) * cosP,
];
const out: Cubic[] = [];
let t1 = theta1;
let px = x1;
let py = y1;
for (let i = 0; i < count; i++) {
const t2 = t1 + delta;
const p2 = point(t2);
const d1 = deriv(t1);
const d2 = deriv(t2);
out.push([
[px + alpha * d1[0], py + alpha * d1[1]],
[p2[0] - alpha * d2[0], p2[1] - alpha * d2[1]],
p2,
]);
px = p2[0];
py = p2[1];
t1 = t2;
}
// The parametric round-trip lands ~5e-8 off the stated endpoint. SVG
// guarantees an arc ends exactly there, and downstream code compares
// endpoints exactly (to decide whether a subpath needs a closing segment),
// so snap it rather than let the drift accumulate across chained arcs.
if (out.length > 0) out[out.length - 1][2] = [x2, y2];
return out;
}
/** Parse a `d` string into absolute subpaths whose only curve type is cubic. */
function toSubpaths(d: string): Subpath[] {
const subs: Subpath[] = [];
let current: Subpath | null = null;
let cx = 0;
let cy = 0;
let sx = 0;
let sy = 0;
// Reflected control points for the S and T shorthands.
let lastC: Pt | null = null;
let lastQ: Pt | null = null;
const push = (c: Cubic) => {
if (!current) {
current = { start: [cx, cy], cubics: [], closed: false };
subs.push(current);
}
current.cubics.push(c);
};
const line = (x: number, y: number) => {
push([
[cx, cy],
[x, y],
[x, y],
]);
cx = x;
cy = y;
};
const re = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(d)) !== null) {
const type = match[1];
const rel = type >= "a" && type <= "z";
const cur = new Cursor(match[2]);
if (type === "Z" || type === "z") {
if (current) current.closed = true;
cx = sx;
cy = sy;
current = null;
lastC = lastQ = null;
continue;
}
let first = true;
// A command's parameters may repeat; `L 1 2 3 4` is two linetos.
while (!cur.done()) {
switch (type.toUpperCase()) {
case "M": {
const x = cur.number() + (rel ? cx : 0);
const y = cur.number() + (rel ? cy : 0);
if (first) {
cx = sx = x;
cy = sy = y;
current = { start: [x, y], cubics: [], closed: false };
subs.push(current);
} else {
// Extra pairs after an M are implicit linetos.
line(x, y);
}
lastC = lastQ = null;
break;
}
case "L": {
line(cur.number() + (rel ? cx : 0), cur.number() + (rel ? cy : 0));
lastC = lastQ = null;
break;
}
case "H": {
line(cur.number() + (rel ? cx : 0), cy);
lastC = lastQ = null;
break;
}
case "V": {
line(cx, cur.number() + (rel ? cy : 0));
lastC = lastQ = null;
break;
}
case "C": {
const c1: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const c2: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
push([c1, c2, p]);
cx = p[0];
cy = p[1];
lastC = c2;
lastQ = null;
break;
}
case "S": {
const c1: Pt = lastC
? [2 * cx - lastC[0], 2 * cy - lastC[1]]
: [cx, cy];
const c2: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
push([c1, c2, p]);
cx = p[0];
cy = p[1];
lastC = c2;
lastQ = null;
break;
}
case "Q":
case "T": {
let q: Pt;
if (type.toUpperCase() === "Q") {
q = [cur.number() + (rel ? cx : 0), cur.number() + (rel ? cy : 0)];
} else {
q = lastQ ? [2 * cx - lastQ[0], 2 * cy - lastQ[1]] : [cx, cy];
}
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
// Exact quadratic → cubic: controls sit 2/3 of the way to Q.
push([
[cx + (2 / 3) * (q[0] - cx), cy + (2 / 3) * (q[1] - cy)],
[p[0] + (2 / 3) * (q[0] - p[0]), p[1] + (2 / 3) * (q[1] - p[1])],
p,
]);
cx = p[0];
cy = p[1];
lastQ = q;
lastC = null;
break;
}
case "A": {
const rx = cur.number();
const ry = cur.number();
const rot = cur.number();
const fA = cur.flag();
const fS = cur.flag();
const x = cur.number() + (rel ? cx : 0);
const y = cur.number() + (rel ? cy : 0);
for (const c of arcToCubics(cx, cy, rx, ry, rot, fA, fS, x, y)) {
push(c);
}
cx = x;
cy = y;
lastC = lastQ = null;
break;
}
}
first = false;
}
}
return subs.filter((s) => s.cubics.length > 0);
}
// ---------------------------------------------------------------------------
// 2 · Resampling
// ---------------------------------------------------------------------------
const FLATTEN_STEPS = 24;
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p1: Pt, t: number): Pt {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const dd = t * t * t;
return [
a * p0[0] + b * c1[0] + c * c2[0] + dd * p1[0],
a * p0[1] + b * c1[1] + c * c2[1] + dd * p1[1],
];
}
/**
* Flatten to a dense polyline with a cumulative-length table, then walk it at
* equal arc-length intervals. Sampling by curve parameter instead of arc length
* bunches points where the curvature is high and the correspondence drifts.
*/
function resample(sub: Subpath, count: number): Sampled {
const dense: Pt[] = [sub.start];
let cursor = sub.start;
for (const [c1, c2, end] of sub.cubics) {
for (let i = 1; i <= FLATTEN_STEPS; i++) {
dense.push(cubicAt(cursor, c1, c2, end, i / FLATTEN_STEPS));
}
cursor = end;
}
if (sub.closed) {
const last = dense[dense.length - 1];
if (last[0] !== sub.start[0] || last[1] !== sub.start[1]) {
dense.push([sub.start[0], sub.start[1]]);
}
}
const cum: number[] = [0];
for (let i = 1; i < dense.length; i++) {
cum.push(
cum[i - 1] +
Math.hypot(
dense[i][0] - dense[i - 1][0],
dense[i][1] - dense[i - 1][1],
),
);
}
const total = cum[cum.length - 1];
const pts: Pt[] = [];
if (total === 0) {
for (let i = 0; i < count; i++) pts.push([dense[0][0], dense[0][1]]);
return { pts, closed: sub.closed };
}
let seg = 0;
for (let i = 0; i < count; i++) {
// Closed paths wrap, so the last sample must not duplicate the first;
// open paths must land exactly on both tips.
const frac = sub.closed ? i / count : i / (count - 1);
const target = frac * total;
while (seg < cum.length - 2 && cum[seg + 1] < target) seg++;
const span = cum[seg + 1] - cum[seg];
const local = span === 0 ? 0 : (target - cum[seg]) / span;
pts.push([
dense[seg][0] + (dense[seg + 1][0] - dense[seg][0]) * local,
dense[seg][1] + (dense[seg + 1][1] - dense[seg][1]) * local,
]);
}
return { pts, closed: sub.closed };
}
// ---------------------------------------------------------------------------
// 3 · Pairing and ordering
// ---------------------------------------------------------------------------
function centroid(pts: Pt[]): Pt {
let x = 0;
let y = 0;
for (const p of pts) {
x += p[0];
y += p[1];
}
return [x / pts.length, y / pts.length];
}
/** Centroid of whichever subpath in `list` sits nearest to `pt`. */
function nearestCentroid(list: Sampled[], pt: Pt): Pt {
let best = list[0];
let bestD = Infinity;
for (const s of list) {
const c = centroid(s.pts);
const dd = (c[0] - pt[0]) ** 2 + (c[1] - pt[1]) ** 2;
if (dd < bestD) {
bestD = dd;
best = s;
}
}
return centroid(best.pts);
}
/**
* Pair subpaths across the two icons. Counts often differ (menu's three lines
* → close's two), so any unpaired subpath is matched against a copy of itself
* collapsed onto the nearest counterpart's centroid: it shrinks into the shape
* it's joining, or grows out of it, rather than popping.
*/
function pairSubpaths(
a: Sampled[],
b: Sampled[],
): { a: Sampled; b: Sampled }[] {
const byCentroid = (p: Sampled, q: Sampled) => {
const cp = centroid(p.pts);
const cq = centroid(q.pts);
return cp[1] - cq[1] || cp[0] - cq[0];
};
const A = [...a].sort(byCentroid);
const B = [...b].sort(byCentroid);
const out: { a: Sampled; b: Sampled }[] = [];
for (let i = 0; i < Math.max(A.length, B.length); i++) {
if (i < A.length && i < B.length) {
out.push({ a: A[i], b: B[i] });
} else if (i >= B.length) {
const src = A[i];
const to = nearestCentroid(B, centroid(src.pts));
out.push({
a: src,
b: { pts: src.pts.map(() => [...to] as Pt), closed: src.closed },
});
} else {
const src = B[i];
const from = nearestCentroid(A, centroid(src.pts));
out.push({
a: { pts: src.pts.map(() => [...from] as Pt), closed: src.closed },
b: src,
});
}
}
return out;
}
/**
* **Step 4 — the one people skip.** Choose the rotation and direction of `b`
* that minimises total squared travel from `a`.
*
* Rotation only applies to closed subpaths; rotating an open one would drag its
* endpoints into the middle of the stroke. Direction applies to both, and it is
* what handles a clockwise shape morphing into a counter-clockwise one — so
* there is no separate winding-normalisation pass.
*/
function matchOrder(a: Pt[], b: Pt[], closed: boolean): Pt[] {
const n = a.length;
const reversed = [...b].reverse();
let bestCost = Infinity;
let bestRot = 0;
let bestRev = false;
for (const rev of [false, true]) {
const src = rev ? reversed : b;
const rotations = closed ? n : 1;
for (let r = 0; r < rotations; r++) {
let cost = 0;
for (let i = 0; i < n; i++) {
const p = src[(i + r) % n];
const dx = a[i][0] - p[0];
const dy = a[i][1] - p[1];
cost += dx * dx + dy * dy;
if (cost >= bestCost) break;
}
if (cost < bestCost) {
bestCost = cost;
bestRot = r;
bestRev = rev;
}
}
}
const src = bestRev ? reversed : b;
const out: Pt[] = new Array(n);
for (let i = 0; i < n; i++) out[i] = src[(i + bestRot) % n];
return out;
}
// ---------------------------------------------------------------------------
// 4 · The interpolator
// ---------------------------------------------------------------------------
const round = (n: number) => Math.round(n * 1000) / 1000;
/** Build a `t → d` function for one icon pair. Called once, never per frame. */
export function buildMorph(
from: string,
to: string,
samples: number,
): (t: number) => string {
const A = toSubpaths(from).map((s) => resample(s, samples));
const B = toSubpaths(to).map((s) => resample(s, samples));
if (A.length === 0 || B.length === 0) {
return (t) => (t < 0.5 ? from : to);
}
const pairs = pairSubpaths(A, B).map(({ a, b }) => ({
a: a.pts,
// Both sides closed, or treat as an open stroke — see the header.
closed: a.closed && b.closed,
b: matchOrder(a.pts, b.pts, a.closed && b.closed),
}));
return (t: number) => {
// At rest, hand back the authored path so the idle icon is never a
// polyline approximation of itself.
if (t <= 0.0005) return from;
if (t >= 0.9995) return to;
let d = "";
for (const { a, b, closed } of pairs) {
for (let i = 0; i < a.length; i++) {
const x = round(a[i][0] + (b[i][0] - a[i][0]) * t);
const y = round(a[i][1] + (b[i][1] - a[i][1]) * t);
d += `${i === 0 ? "M" : "L"}${x} ${y}`;
}
if (closed) d += "Z";
}
return d;
};
}
// ---------------------------------------------------------------------------
// 5 · The component
// ---------------------------------------------------------------------------
const DEFAULT_SPRING: SpringOptions = {
stiffness: 300,
damping: 30,
mass: 0.6,
};
const isMotionValue = (v: unknown): v is MotionValue<number> =>
typeof v === "object" &&
v !== null &&
typeof (v as MotionValue<number>).get === "function";
export interface MorphIconProps extends Omit<
React.ComponentProps<"svg">,
"progress"
> {
/** Path data for state 0. Single or multiple subpaths. */
from: string;
/** Path data for state 1. */
to: string;
/**
* External 0→1 driver. A `MotionValue` keeps the whole morph off React's
* render path; a plain number is synced into one for you. Takes precedence
* over `active`.
*/
progress?: MotionValue<number> | number;
/** Convenience driver: springs 0↔1. Ignored when `progress` is given. */
active?: boolean;
/** Points sampled per subpath. Higher = rounder corners mid-flight. */
samples?: number;
/** Spring for the `active` driver. */
spring?: SpringOptions;
/** Accessible name. Omit and the icon is `aria-hidden`. */
label?: string;
}
export function MorphIcon({
from,
to,
progress,
active = false,
samples = 72,
spring = DEFAULT_SPRING,
label,
className,
viewBox = "0 0 24 24",
fill = "none",
stroke = "currentColor",
strokeWidth = 2,
strokeLinecap = "round",
strokeLinejoin = "round",
...props
}: MorphIconProps) {
const reduce = useReducedMotion();
// Normalised once per pair — this is the expensive half, and it must never
// run inside the frame loop.
const morph = React.useMemo(
() => buildMorph(from, to, samples),
[from, to, samples],
);
const target = useMotionValue(active ? 1 : 0);
const sprung = useSpring(target, spring);
const external = useMotionValue(typeof progress === "number" ? progress : 0);
React.useEffect(() => {
target.set(active ? 1 : 0);
}, [active, target]);
React.useEffect(() => {
if (typeof progress === "number") external.set(progress);
}, [progress, external]);
const t = isMotionValue(progress)
? progress
: typeof progress === "number"
? external
: // Reduced motion reads the raw target, so the icon swaps rather than
// travels. An external driver is the consumer's to decide.
reduce
? target
: sprung;
const d = useTransform(t, morph);
return (
<svg
data-slot="morph-icon"
viewBox={viewBox}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn("size-6", className)}
{...props}
>
<motion.path data-slot="morph-icon-path" d={d} />
</svg>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/morph-icon.json1. Install dependencies
npm install motion clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* MorphIcon — true SVG path interpolation between two icons.
*
* Not a cross-fade. Two arbitrary `d` strings have different command counts and
* different command types, so they cannot be lerped directly; this file builds
* the normalizer that makes them lerpable, with no dependency.
*
* The pipeline, run **once per icon pair** (memoised on `from`/`to`/`samples`),
* never per frame:
*
* 1. **Parse** to absolute commands. A cursor-based scanner, not a global
* number regex, because arc flags are allowed to be packed without
* separators — `a1 1 0 015 5` is legal and means flags `0`,`1` then `5`.
* A naive `/-?[\d.]+/g` reads that as the single number `015` and silently
* produces a wrong shape.
* 2. **Reduce to one command type.** `H`/`V`/`L` become degenerate cubics,
* `Q`/`T` lift to cubics exactly (control points at 2/3), and `A` goes
* through the full endpoint→centre parameterisation of SVG spec F.6.5,
* split at 90° and approximated per arc with `alpha = 4/3·tan(Δθ/4)`.
* After this everything is `M` + cubics (+ optional close).
* 3. **Resample** each subpath to an equal point count at equal arc length,
* by flattening the cubics and walking the cumulative-length table.
* Closed subpaths sample `i/n` (wrapping); open ones sample `i/(n-1)` so
* both endpoints land exactly on the tips.
* 4. **Rotate the point ordering** to minimise total travel. This is the step
* people skip, and it is the whole difference between a morph that looks
* intentional and one that ties itself in a knot. For closed subpaths all
* `n` rotations are scored; for open ones rotation would move the
* endpoints, so only the direction is chosen. Both cases also score the
* **reversed** traversal, which is what fixes a clockwise shape morphing
* into a counter-clockwise one — no separate winding-normalisation pass
* is needed, the search subsumes it.
*
* ## Multiple subpaths
*
* Fully supported — `menu` (three lines) → `close` (two lines) is a 3→2 pairing
* and one of the three named test cases, so scoping v1 to single subpaths would
* have failed the first icon. Subpaths are sorted into a canonical order by
* centroid and paired by index; when the counts differ, each unpaired subpath
* is matched against a **degenerate copy of itself collapsed onto the nearest
* counterpart's centroid**, so the extra line shrinks into the shape it is
* joining instead of vanishing.
*
* ## Two deliberate limits
*
* - The morph renders as a polyline of `samples` points, so mid-flight a sharp
* corner is rounded by at most half the sample spacing (sub-pixel at icon
* size). At rest it is exact: `t ≤ 0.0005` and `t ≥ 0.9995` return the
* original `d` strings verbatim, so the idle icon is never an approximation.
* - A pair is closed only when **both** sides are closed. Morphing an open
* stroke into a filled shape has no correct answer; the open reading is the
* safe one for icon sets, which are overwhelmingly strokes.
*/
type Pt = [number, number];
/** [control1, control2, end] — the start point is the previous end. */
type Cubic = [Pt, Pt, Pt];
interface Subpath {
start: Pt;
cubics: Cubic[];
closed: boolean;
}
interface Sampled {
pts: Pt[];
closed: boolean;
}
// ---------------------------------------------------------------------------
// 1 · Parsing
// ---------------------------------------------------------------------------
/**
* A cursor over a path's parameter list. Needed instead of a number regex
* because the arc command's two flags may be written as bare digits with no
* separator, so position — not pattern — decides how to read them.
*/
class Cursor {
private i = 0;
private readonly s: string;
constructor(s: string) {
this.s = s;
}
private skip() {
while (this.i < this.s.length) {
const c = this.s[this.i];
if (c === " " || c === "," || c === "\n" || c === "\r" || c === "\t") {
this.i++;
} else {
break;
}
}
}
done(): boolean {
this.skip();
return this.i >= this.s.length;
}
number(): number {
this.skip();
const start = this.i;
if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
if (this.s[this.i] === ".") {
this.i++;
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
}
if (this.s[this.i] === "e" || this.s[this.i] === "E") {
const mark = this.i;
this.i++;
if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
if (this.s[this.i] >= "0" && this.s[this.i] <= "9") {
while (
this.i < this.s.length &&
this.s[this.i] >= "0" &&
this.s[this.i] <= "9"
)
this.i++;
} else {
this.i = mark;
}
}
const n = Number(this.s.slice(start, this.i));
return Number.isFinite(n) ? n : 0;
}
/** An arc flag: a single `0` or `1`, which may be glued to the next number. */
flag(): number {
this.skip();
const c = this.s[this.i];
if (c === "0" || c === "1") {
this.i++;
return c === "1" ? 1 : 0;
}
return this.number() ? 1 : 0;
}
}
/**
* Endpoint → centre parameterisation (SVG spec F.6.5), then a cubic per ≤90°
* of sweep. Anything less than the real conversion visibly distorts a circle.
*/
function arcToCubics(
x1: number,
y1: number,
rxIn: number,
ryIn: number,
phiDeg: number,
fA: number,
fS: number,
x2: number,
y2: number,
): Cubic[] {
if (rxIn === 0 || ryIn === 0 || (x1 === x2 && y1 === y2)) {
return [
[
[x1, y1],
[x2, y2],
[x2, y2],
],
];
}
let rx = Math.abs(rxIn);
let ry = Math.abs(ryIn);
const phi = (phiDeg * Math.PI) / 180;
const cosP = Math.cos(phi);
const sinP = Math.sin(phi);
const dx = (x1 - x2) / 2;
const dy = (y1 - y2) / 2;
const x1p = cosP * dx + sinP * dy;
const y1p = -sinP * dx + cosP * dy;
// F.6.6 — scale the radii up if they can't span the endpoints.
const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if (lambda > 1) {
const s = Math.sqrt(lambda);
rx *= s;
ry *= s;
}
const sign = fA === fS ? -1 : 1;
const numer = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
const denom = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
const co = sign * Math.sqrt(Math.max(0, numer / denom));
const cxp = (co * (rx * y1p)) / ry;
const cyp = (co * (-ry * x1p)) / rx;
const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
const angle = (ux: number, uy: number, vx: number, vy: number) => {
const dot = ux * vx + uy * vy;
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
let a = Math.acos(Math.min(1, Math.max(-1, len === 0 ? 1 : dot / len)));
if (ux * vy - uy * vx < 0) a = -a;
return a;
};
const ux = (x1p - cxp) / rx;
const uy = (y1p - cyp) / ry;
const vx = (-x1p - cxp) / rx;
const vy = (-y1p - cyp) / ry;
const theta1 = angle(1, 0, ux, uy);
let dTheta = angle(ux, uy, vx, vy);
if (fS === 0 && dTheta > 0) dTheta -= 2 * Math.PI;
if (fS === 1 && dTheta < 0) dTheta += 2 * Math.PI;
const count = Math.max(1, Math.ceil(Math.abs(dTheta) / (Math.PI / 2)));
const delta = dTheta / count;
const alpha = (4 / 3) * Math.tan(delta / 4);
const point = (t: number): Pt => [
cx + rx * Math.cos(t) * cosP - ry * Math.sin(t) * sinP,
cy + rx * Math.cos(t) * sinP + ry * Math.sin(t) * cosP,
];
const deriv = (t: number): Pt => [
-rx * Math.sin(t) * cosP - ry * Math.cos(t) * sinP,
-rx * Math.sin(t) * sinP + ry * Math.cos(t) * cosP,
];
const out: Cubic[] = [];
let t1 = theta1;
let px = x1;
let py = y1;
for (let i = 0; i < count; i++) {
const t2 = t1 + delta;
const p2 = point(t2);
const d1 = deriv(t1);
const d2 = deriv(t2);
out.push([
[px + alpha * d1[0], py + alpha * d1[1]],
[p2[0] - alpha * d2[0], p2[1] - alpha * d2[1]],
p2,
]);
px = p2[0];
py = p2[1];
t1 = t2;
}
// The parametric round-trip lands ~5e-8 off the stated endpoint. SVG
// guarantees an arc ends exactly there, and downstream code compares
// endpoints exactly (to decide whether a subpath needs a closing segment),
// so snap it rather than let the drift accumulate across chained arcs.
if (out.length > 0) out[out.length - 1][2] = [x2, y2];
return out;
}
/** Parse a `d` string into absolute subpaths whose only curve type is cubic. */
function toSubpaths(d: string): Subpath[] {
const subs: Subpath[] = [];
let current: Subpath | null = null;
let cx = 0;
let cy = 0;
let sx = 0;
let sy = 0;
// Reflected control points for the S and T shorthands.
let lastC: Pt | null = null;
let lastQ: Pt | null = null;
const push = (c: Cubic) => {
if (!current) {
current = { start: [cx, cy], cubics: [], closed: false };
subs.push(current);
}
current.cubics.push(c);
};
const line = (x: number, y: number) => {
push([
[cx, cy],
[x, y],
[x, y],
]);
cx = x;
cy = y;
};
const re = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(d)) !== null) {
const type = match[1];
const rel = type >= "a" && type <= "z";
const cur = new Cursor(match[2]);
if (type === "Z" || type === "z") {
if (current) current.closed = true;
cx = sx;
cy = sy;
current = null;
lastC = lastQ = null;
continue;
}
let first = true;
// A command's parameters may repeat; `L 1 2 3 4` is two linetos.
while (!cur.done()) {
switch (type.toUpperCase()) {
case "M": {
const x = cur.number() + (rel ? cx : 0);
const y = cur.number() + (rel ? cy : 0);
if (first) {
cx = sx = x;
cy = sy = y;
current = { start: [x, y], cubics: [], closed: false };
subs.push(current);
} else {
// Extra pairs after an M are implicit linetos.
line(x, y);
}
lastC = lastQ = null;
break;
}
case "L": {
line(cur.number() + (rel ? cx : 0), cur.number() + (rel ? cy : 0));
lastC = lastQ = null;
break;
}
case "H": {
line(cur.number() + (rel ? cx : 0), cy);
lastC = lastQ = null;
break;
}
case "V": {
line(cx, cur.number() + (rel ? cy : 0));
lastC = lastQ = null;
break;
}
case "C": {
const c1: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const c2: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
push([c1, c2, p]);
cx = p[0];
cy = p[1];
lastC = c2;
lastQ = null;
break;
}
case "S": {
const c1: Pt = lastC
? [2 * cx - lastC[0], 2 * cy - lastC[1]]
: [cx, cy];
const c2: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
push([c1, c2, p]);
cx = p[0];
cy = p[1];
lastC = c2;
lastQ = null;
break;
}
case "Q":
case "T": {
let q: Pt;
if (type.toUpperCase() === "Q") {
q = [cur.number() + (rel ? cx : 0), cur.number() + (rel ? cy : 0)];
} else {
q = lastQ ? [2 * cx - lastQ[0], 2 * cy - lastQ[1]] : [cx, cy];
}
const p: Pt = [
cur.number() + (rel ? cx : 0),
cur.number() + (rel ? cy : 0),
];
// Exact quadratic → cubic: controls sit 2/3 of the way to Q.
push([
[cx + (2 / 3) * (q[0] - cx), cy + (2 / 3) * (q[1] - cy)],
[p[0] + (2 / 3) * (q[0] - p[0]), p[1] + (2 / 3) * (q[1] - p[1])],
p,
]);
cx = p[0];
cy = p[1];
lastQ = q;
lastC = null;
break;
}
case "A": {
const rx = cur.number();
const ry = cur.number();
const rot = cur.number();
const fA = cur.flag();
const fS = cur.flag();
const x = cur.number() + (rel ? cx : 0);
const y = cur.number() + (rel ? cy : 0);
for (const c of arcToCubics(cx, cy, rx, ry, rot, fA, fS, x, y)) {
push(c);
}
cx = x;
cy = y;
lastC = lastQ = null;
break;
}
}
first = false;
}
}
return subs.filter((s) => s.cubics.length > 0);
}
// ---------------------------------------------------------------------------
// 2 · Resampling
// ---------------------------------------------------------------------------
const FLATTEN_STEPS = 24;
function cubicAt(p0: Pt, c1: Pt, c2: Pt, p1: Pt, t: number): Pt {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const dd = t * t * t;
return [
a * p0[0] + b * c1[0] + c * c2[0] + dd * p1[0],
a * p0[1] + b * c1[1] + c * c2[1] + dd * p1[1],
];
}
/**
* Flatten to a dense polyline with a cumulative-length table, then walk it at
* equal arc-length intervals. Sampling by curve parameter instead of arc length
* bunches points where the curvature is high and the correspondence drifts.
*/
function resample(sub: Subpath, count: number): Sampled {
const dense: Pt[] = [sub.start];
let cursor = sub.start;
for (const [c1, c2, end] of sub.cubics) {
for (let i = 1; i <= FLATTEN_STEPS; i++) {
dense.push(cubicAt(cursor, c1, c2, end, i / FLATTEN_STEPS));
}
cursor = end;
}
if (sub.closed) {
const last = dense[dense.length - 1];
if (last[0] !== sub.start[0] || last[1] !== sub.start[1]) {
dense.push([sub.start[0], sub.start[1]]);
}
}
const cum: number[] = [0];
for (let i = 1; i < dense.length; i++) {
cum.push(
cum[i - 1] +
Math.hypot(
dense[i][0] - dense[i - 1][0],
dense[i][1] - dense[i - 1][1],
),
);
}
const total = cum[cum.length - 1];
const pts: Pt[] = [];
if (total === 0) {
for (let i = 0; i < count; i++) pts.push([dense[0][0], dense[0][1]]);
return { pts, closed: sub.closed };
}
let seg = 0;
for (let i = 0; i < count; i++) {
// Closed paths wrap, so the last sample must not duplicate the first;
// open paths must land exactly on both tips.
const frac = sub.closed ? i / count : i / (count - 1);
const target = frac * total;
while (seg < cum.length - 2 && cum[seg + 1] < target) seg++;
const span = cum[seg + 1] - cum[seg];
const local = span === 0 ? 0 : (target - cum[seg]) / span;
pts.push([
dense[seg][0] + (dense[seg + 1][0] - dense[seg][0]) * local,
dense[seg][1] + (dense[seg + 1][1] - dense[seg][1]) * local,
]);
}
return { pts, closed: sub.closed };
}
// ---------------------------------------------------------------------------
// 3 · Pairing and ordering
// ---------------------------------------------------------------------------
function centroid(pts: Pt[]): Pt {
let x = 0;
let y = 0;
for (const p of pts) {
x += p[0];
y += p[1];
}
return [x / pts.length, y / pts.length];
}
/** Centroid of whichever subpath in `list` sits nearest to `pt`. */
function nearestCentroid(list: Sampled[], pt: Pt): Pt {
let best = list[0];
let bestD = Infinity;
for (const s of list) {
const c = centroid(s.pts);
const dd = (c[0] - pt[0]) ** 2 + (c[1] - pt[1]) ** 2;
if (dd < bestD) {
bestD = dd;
best = s;
}
}
return centroid(best.pts);
}
/**
* Pair subpaths across the two icons. Counts often differ (menu's three lines
* → close's two), so any unpaired subpath is matched against a copy of itself
* collapsed onto the nearest counterpart's centroid: it shrinks into the shape
* it's joining, or grows out of it, rather than popping.
*/
function pairSubpaths(
a: Sampled[],
b: Sampled[],
): { a: Sampled; b: Sampled }[] {
const byCentroid = (p: Sampled, q: Sampled) => {
const cp = centroid(p.pts);
const cq = centroid(q.pts);
return cp[1] - cq[1] || cp[0] - cq[0];
};
const A = [...a].sort(byCentroid);
const B = [...b].sort(byCentroid);
const out: { a: Sampled; b: Sampled }[] = [];
for (let i = 0; i < Math.max(A.length, B.length); i++) {
if (i < A.length && i < B.length) {
out.push({ a: A[i], b: B[i] });
} else if (i >= B.length) {
const src = A[i];
const to = nearestCentroid(B, centroid(src.pts));
out.push({
a: src,
b: { pts: src.pts.map(() => [...to] as Pt), closed: src.closed },
});
} else {
const src = B[i];
const from = nearestCentroid(A, centroid(src.pts));
out.push({
a: { pts: src.pts.map(() => [...from] as Pt), closed: src.closed },
b: src,
});
}
}
return out;
}
/**
* **Step 4 — the one people skip.** Choose the rotation and direction of `b`
* that minimises total squared travel from `a`.
*
* Rotation only applies to closed subpaths; rotating an open one would drag its
* endpoints into the middle of the stroke. Direction applies to both, and it is
* what handles a clockwise shape morphing into a counter-clockwise one — so
* there is no separate winding-normalisation pass.
*/
function matchOrder(a: Pt[], b: Pt[], closed: boolean): Pt[] {
const n = a.length;
const reversed = [...b].reverse();
let bestCost = Infinity;
let bestRot = 0;
let bestRev = false;
for (const rev of [false, true]) {
const src = rev ? reversed : b;
const rotations = closed ? n : 1;
for (let r = 0; r < rotations; r++) {
let cost = 0;
for (let i = 0; i < n; i++) {
const p = src[(i + r) % n];
const dx = a[i][0] - p[0];
const dy = a[i][1] - p[1];
cost += dx * dx + dy * dy;
if (cost >= bestCost) break;
}
if (cost < bestCost) {
bestCost = cost;
bestRot = r;
bestRev = rev;
}
}
}
const src = bestRev ? reversed : b;
const out: Pt[] = new Array(n);
for (let i = 0; i < n; i++) out[i] = src[(i + bestRot) % n];
return out;
}
// ---------------------------------------------------------------------------
// 4 · The interpolator
// ---------------------------------------------------------------------------
const round = (n: number) => Math.round(n * 1000) / 1000;
/** Build a `t → d` function for one icon pair. Called once, never per frame. */
export function buildMorph(
from: string,
to: string,
samples: number,
): (t: number) => string {
const A = toSubpaths(from).map((s) => resample(s, samples));
const B = toSubpaths(to).map((s) => resample(s, samples));
if (A.length === 0 || B.length === 0) {
return (t) => (t < 0.5 ? from : to);
}
const pairs = pairSubpaths(A, B).map(({ a, b }) => ({
a: a.pts,
// Both sides closed, or treat as an open stroke — see the header.
closed: a.closed && b.closed,
b: matchOrder(a.pts, b.pts, a.closed && b.closed),
}));
return (t: number) => {
// At rest, hand back the authored path so the idle icon is never a
// polyline approximation of itself.
if (t <= 0.0005) return from;
if (t >= 0.9995) return to;
let d = "";
for (const { a, b, closed } of pairs) {
for (let i = 0; i < a.length; i++) {
const x = round(a[i][0] + (b[i][0] - a[i][0]) * t);
const y = round(a[i][1] + (b[i][1] - a[i][1]) * t);
d += `${i === 0 ? "M" : "L"}${x} ${y}`;
}
if (closed) d += "Z";
}
return d;
};
}
// ---------------------------------------------------------------------------
// 5 · The component
// ---------------------------------------------------------------------------
const DEFAULT_SPRING: SpringOptions = {
stiffness: 300,
damping: 30,
mass: 0.6,
};
const isMotionValue = (v: unknown): v is MotionValue<number> =>
typeof v === "object" &&
v !== null &&
typeof (v as MotionValue<number>).get === "function";
export interface MorphIconProps extends Omit<
React.ComponentProps<"svg">,
"progress"
> {
/** Path data for state 0. Single or multiple subpaths. */
from: string;
/** Path data for state 1. */
to: string;
/**
* External 0→1 driver. A `MotionValue` keeps the whole morph off React's
* render path; a plain number is synced into one for you. Takes precedence
* over `active`.
*/
progress?: MotionValue<number> | number;
/** Convenience driver: springs 0↔1. Ignored when `progress` is given. */
active?: boolean;
/** Points sampled per subpath. Higher = rounder corners mid-flight. */
samples?: number;
/** Spring for the `active` driver. */
spring?: SpringOptions;
/** Accessible name. Omit and the icon is `aria-hidden`. */
label?: string;
}
export function MorphIcon({
from,
to,
progress,
active = false,
samples = 72,
spring = DEFAULT_SPRING,
label,
className,
viewBox = "0 0 24 24",
fill = "none",
stroke = "currentColor",
strokeWidth = 2,
strokeLinecap = "round",
strokeLinejoin = "round",
...props
}: MorphIconProps) {
const reduce = useReducedMotion();
// Normalised once per pair — this is the expensive half, and it must never
// run inside the frame loop.
const morph = React.useMemo(
() => buildMorph(from, to, samples),
[from, to, samples],
);
const target = useMotionValue(active ? 1 : 0);
const sprung = useSpring(target, spring);
const external = useMotionValue(typeof progress === "number" ? progress : 0);
React.useEffect(() => {
target.set(active ? 1 : 0);
}, [active, target]);
React.useEffect(() => {
if (typeof progress === "number") external.set(progress);
}, [progress, external]);
const t = isMotionValue(progress)
? progress
: typeof progress === "number"
? external
: // Reduced motion reads the raw target, so the icon swaps rather than
// travels. An external driver is the consumer's to decide.
reduce
? target
: sprung;
const d = useTransform(t, morph);
return (
<svg
data-slot="morph-icon"
viewBox={viewBox}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn("size-6", className)}
{...props}
>
<motion.path data-slot="morph-icon-path" d={d} />
</svg>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
from / to | string | — | Path data for state 0 and state 1. Any SVG path grammar — absolute or relative, `H`/`V`/`S`/`Q`/`T`/`A` shorthands, packed arc flags, exponent notation. Multiple subpaths are supported and paired automatically; the counts do not need to match. |
progress | MotionValue<number> | number | — | External 0→1 driver, and it takes precedence over `active`. Passing a `MotionValue` keeps the entire morph off React's render path — scrubbing it re-renders nothing. A plain number is synced into one for you. |
active | boolean | false | Convenience driver that springs 0↔1. Reverses cleanly from wherever it is mid-flight. Ignored when `progress` is set. |
samples | number | 72 | Points sampled per subpath. Mid-flight a sharp corner is rounded by at most half the sample spacing — sub-pixel at icon size. At rest the authored `d` is returned verbatim, so the idle icon is never an approximation. |
spring | SpringOptions | { stiffness: 300, damping: 30, mass: 0.6 } | Spring for the `active` driver. |
label | string | — | Accessible name. Omit and the icon is `aria-hidden` — the usual case, since it normally sits inside a labelled button. |
...props | React.ComponentProps<"svg"> | — | Forwarded to the `<svg>`. `viewBox` defaults to `0 0 24 24`, and it is stroked by default (`fill: none`, `stroke: currentColor`, width 2) — pass `fill="currentColor"` for filled icons. |