Icon Morph
One icon that turns into another by moving its own pieces, so there is never a frame with both glyphs on screen.
Installation
npx shadcn@latest add @loomui/icon-morphUsage
import { IconMorph } from "@/components/ui/icon-morph"const [open, setOpen] = React.useState(false)
<button aria-label={open ? "Close menu" : "Open menu"} onClick={() => setOpen(!open)}>
<IconMorph set="menu" active={open} />
</button>The icon is decorative and carries aria-hidden. The label belongs on whatever
wraps it, and it changes with the state, because a button that reads "Open menu"
while showing a close icon is worse than no label at all.
Sets
set | active false | active true |
|---|---|---|
menu | hamburger | close |
plus | plus | close |
play | play | pause |
chevron | chevron | tick |
How it works
Do not crossfade two icons
The usual icon swap fades one glyph out while the other fades in. For a frame or two both are legible, stacked on top of each other, and that reads as a rendering fault rather than a transition. It is the same mistake as covering a gap between two states by overlapping them.
Nothing here fades into anything. Every set is drawn from pieces that belong to both shapes, and those pieces travel. The hamburger's top bar is the top arm of the X. The plus is an X that has not been turned yet.
Rotation is a rotation, not a straight line
Where a piece ends up somewhere it could have turned to, it turns.
<path d="M4 6h16" style={pose("12px 6px", "translateY(6px) rotate(45deg)")} />Each bar spins about its own centre and then slides to the middle. The origin
is given in viewBox units and transform-box: view-box is stated rather than
assumed, since browsers have shipped different initial values for it.
Interpolating those same two bars point by point would drag their ends through the middle instead, and the arms would go visibly short halfway across.
Where the outline itself changes, animate the outline
A triangle is not a rotated pair of bars, so play cannot be a transform. That
pair morphs by interpolating its outline point by point.
The triangle is cut down its middle so both states are two four-point quadrilaterals. The right half of the triangle is a quad with its two right-hand points sitting on top of each other, which is how a tip and a bar end up with the same point count.
const PLAY = [7.5, 4.5, 13.25, 8.25, 13.25, 15.75, 7.5, 19.5, ...]
const PAUSE = [7, 4.5, 10.5, 4.5, 10.5, 19.5, 7, 19.5, ...]chevron is the same idea with less work: three points in both states, so one
path covers the journey.
Both halves of play live in one path rather than two elements. As two
elements the shared edge down the middle is a boundary each of them
antialiases against, and currentColor is rarely fully opaque, so the seam
shows until the triangle reads as the pause bars in disguise. One path is one
fill: the halves union and the join disappears. For the same reason there is no
stroke on that set, since a stroke follows every edge of every subpath,
including the two interior ones.
Why the outline is not left to CSS
The CSS d property does exactly this interpolation declaratively, and it is
what this component used first. It is also the one thing here that had to be
taken back out.
Safari does not implement d as a CSS property. On iOS that means every
browser, because they are all WebKit underneath. Left to CSS, two of the four
sets cut instead of morphing on every phone ever made, and only on phones,
which is the kind of bug that survives a long time because it never reproduces
on the machine it was written on.
So the points are walked in a requestAnimationFrame loop and written to the
d attribute, which every engine has understood since SVG shipped.
paint(start.map((value, i) => value + (target[i] - value) * eased))It costs a paint per frame on a 24px icon, which is nothing, and it is the same
cost in every browser rather than a morph in some and a cut in others. The
easing is a real quartic, 1 - (1 - t) ** 4, because --ease-out-quart is the
cubic-bezier approximation of that curve and the two techniques have to stay in
step with each other.
The escape hatch has to be a class
duration is a custom property set on the root, so it reaches pieces nested
inside a mask. The transition itself is a class, not an inline style.
"transition-[transform,opacity] [transition-duration:var(--icon-morph-duration)] motion-reduce:transition-none"An inline transition would outrank motion-reduce:transition-none and the
reduced-motion escape would quietly stop working. The rule is worth keeping
anywhere a component writes styles from JavaScript: whatever the user's setting
has to be able to override must live at the same specificity it does.
The two reshaping sets cannot use that class, since nothing about them is a CSS
transition. They read prefers-reduced-motion in JavaScript and jump straight
to the finished shape.
Props
IconMorph takes everything an svg takes, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
set | IconMorphSet | "menu" | Which pair of shapes to travel between. |
active | boolean | false | false shows the first shape. |
duration | number | 220 | Milliseconds for the morph. |
strokeWidth | number | 2 | Stroke weight, in viewBox units. |
Size it with a class. It defaults to size-6 and takes the current colour.
<IconMorph
set="chevron"
active={done}
className="text-muted-foreground size-5"
/>Reduced motion
With reduced motion the icon cuts between the two shapes. No piece travels, nothing fades, and both shapes stay exactly as legible as they were.