What it morphs
Icons, any SVG markup, words set in a font, or drawings made by hand: either side can be any of them, and the two needn't match.
Either side of a morph can be any of these, and the two needn't be the same kind: an icon can turn into a word, or a filled drawing into a stroked one.
Icons
An icon is drawn with strokes on a 24-unit grid, as Lucide's and Feather's are. It can be:
- SVG path data:
"M5 12h14M12 5v14". - A list of it, an entry a stroke or more:
["M5 12h14", "M12 5v14"]. - Elements as Lucide writes its icons,
[tag, attributes][]: anything from thelucidepackage as it is, and icons in the same shape from elsewhere.path,line,polyline,polygon,circle,ellipseandrectare read; any other element is an error.
Every element is taken as a stroke, so leave out any that isn't drawn, a bounding box for one. Two icons morph on their grid, and each part's width is a share of the icon's stroke width: 1 as drawn, narrowing to 0 as a stroke shrinks away.
// 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)} />;
Any SVG
svg(markup) reads SVG markup into a drawing, with no DOM, so it runs on a server as well as in a page:
import { morph, svg } from "@cesarvenzac/morph";
const heart = svg(`<svg viewBox="0 0 24 24">
<path fill="#e5484d" d="M12 20.5c-.3 0-.6-.1-.8-.3…Z"/>
</svg>`);
const m = morph(heart, svg(starMarkup));It reads path, rect, circle, ellipse, line, polyline and polygon, inside groups and nested svgs (placed at their x and y, their own viewBox not applied), their transforms applied, with their fill, stroke, stroke width, fill rule, caps, joins, opacity and currentColor, from attributes or inline styles. A shape with a fill and a stroke is two parts. Gradients and patterns are read as the current colour, and what isn't drawn as a path (text, images, use, clips, masks, filters, defs) is passed over.
// Any SVG: Morph reads the markup, with no DOM, paint and all.
import { morph, svg } from "@cesarvenzac/morph";
import { MorphFrame } from "@cesarvenzac/morph/react";
const heart = svg(`<svg viewBox="0 0 24 24">
<path fill="var(--paint-1)" d="M12 20.5c-.3 0-.6-.1-.8-.3…Z"/>
</svg>`);
const star = svg(`<svg viewBox="0 0 24 24">
<path fill="var(--paint-2)" d="M12 2.8 14.5 9.16 21.32 9.57…Z"/>
</svg>`);
// Rose to amber through the colours between, mixed in OKLab.
const m = morph(heart, star);
<MorphFrame morph={m} t={t} />;
Words
text(font, value) sets a word, or a line, in a font opentype.js has read. Each glyph is a filled part, its holes found as SVG fills them; the package reads no font files itself.
import opentype from "opentype.js";
import { morph, text } from "@cesarvenzac/morph";
const file = await fetch("/fonts/Inter-Regular.woff");
const font = opentype.parse(await file.arrayBuffer());
const m = morph(text(font, "Hello"), text(font, "World"));size sets the size it's set at, in the drawing's units (24 unless said), and color its colour (the current colour unless said). opentype.js reads TrueType, OpenType and WOFF files, not WOFF2.
Any object shaped like opentype.js's fonts will do: unitsPerEm, ascender, descender, getPath and getAdvanceWidth, and getPaths for a part a glyph (without it, the line is one part). Where its shaping throws, as opentype.js 2.0 does on the features of some fonts, Inter's and DejaVu's among them, the line is set glyph by glyph instead, through charToGlyph and getKerningValue: no ligatures, the same letters.
// Words: each letter a filled piece, its holes found as SVG finds
// them. In your code, the font is one opentype.js has read:
import opentype from "opentype.js";
import { morph, text } from "@cesarvenzac/morph";
const file = await fetch("/fonts/Inter-Regular.woff");
const font = opentype.parse(await file.arrayBuffer());
// Here, Inter's letters were read ahead of time into a font shaped
// as opentype.js's, so this page loads no font file.
const m = morph(text(font, from), text(font, to), { extra: "split" });
<MorphFrame morph={m} t={t} />;
Drawings by hand
A drawing is plain data. svg and text make one, and so can you:
import type { Drawing } from "@cesarvenzac/morph";
const badge: Drawing = {
viewBox: [0, 0, 24, 24],
parts: [
{ kind: "fill", d: "M4 4h16v16H4Z", color: "#0a84ff" },
{ kind: "stroke", d: "M8 12h8", width: 2, cap: "round" },
],
};A part is a fill or a stroke, its path data in the drawing's units, with an optional color (any CSS colour), opacity, width (a stroke's), fill rule (nonzero or evenodd), cap and join. A text's em is its size: two drawings that both have one are taken as they are rather than fitted (below).
One canvas for two sides
Two icons are drawn on their 24-unit grid. Otherwise every frame is drawn on the first side's canvas, m.viewBox, the second fitted into it as SVG fits a viewBox: centred, as large as fits. Two drawings that both have an em, as words from text do, are taken as they are instead, centred on each other, on a canvas wide enough for both. An icon among drawings is stroked 2 wide, as Lucide draws it.
Inside, every drawing is scaled to a 24-unit grid, which the morph's distances are tuned for (how far apart its points sit, how round a corner gets halfway), and scaled back as it's drawn: a drawing's own size doesn't change how it morphs.