Icon Morph

One icon that turns into another by moving its own pieces, so there is never a frame with both glyphs on screen.

Loading preview

Installation

npx shadcn@latest add @loomui/icon-morph

Usage

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

setactive falseactive true
menuhamburgerclose
plusplusclose
playplaypause
chevronchevrontick

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:

PropTypeDefaultDescription
setIconMorphSet"menu"Which pair of shapes to travel between.
activebooleanfalsefalse shows the first shape.
durationnumber220Milliseconds for the morph.
strokeWidthnumber2Stroke 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.