Icon morph
A line icon whose strokes move into a related glyph: menu to close, plus to minus, a chevron flipping.
import { Button } from "@oration/canon/components/button";import { IconMorph } from "@oration/canon/components/icon-morph";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); const [expanded, setExpanded] = React.useState(false); const [added, setAdded] = React.useState(false); return ( <div className="flex items-center gap-2"> <Button variant="ghost" size="icon" aria-label="Navigation" aria-expanded={open} onClick={() => setOpen((value) => !value)} > <IconMorph icon={open ? "close" : "menu"} /> </Button> <Button variant="ghost" size="icon" aria-label="Supplier details" aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} > <IconMorph icon={expanded ? "chevron-up" : "chevron-down"} /> </Button> <Button variant="outline" onClick={() => setAdded((value) => !value)} > <IconMorph data-icon="inline-start" icon={added ? "check" : "plus"} /> {added ? "Added" : "Add to list"} </Button> </div> );}Usage#
Icon morph turns one line icon into a related one by moving its strokes: a menu folds into a close, a plus turns into a close or flattens into a minus, a chevron flips, an arrow turns around. Every glyph is built from the same three strokes on Lucide's 24 grid, so any glyph morphs into any other and it sits beside Lucide icons unnoticed. Use it when both icons are the same object in two states. When they are different objects, such as copy and check or play and pause, use Icon swap.
When to use
- For a disclosure whose icon shows open or closed: a menu to a close, a chevron down to up.
- For an add control that becomes its own undo or close: a plus to a close, a plus to a check.
- For a sort or direction toggle: an arrow up to an arrow down.
- For an expand and collapse pair: a plus to a minus.
When not to use
- For two different objects: copy to check, play to pause, a spinner to a check, a bell to a muted bell. Use Icon swap
- For a glyph that isn't in the set. Don't draw strokes by hand in an app; add the glyph to Icon morph or use Icon swap. Use Icon swap
- For a chevron that only rotates inside an accordion or collapsible trigger. Those rotate with a transform already. Use Collapsible
The Swap-In-Place Rule
The Three Springs Rule
spring.moderate, critically damped so the shape lands exactly with no overshoot.Anatomy#
- Canvas. A Lucide-compatible
svg: 24 viewBox,currentColorstroke, 2px, round caps and joins,aria-hidden="true". - Carrying strokes. Slots one and three draw the shape. A single-stroke glyph such as a chevron or check draws it in both, so a second stroke folds into the first instead of vanishing.
- Middle stroke. Slot two is the menu's middle bar. On every other glyph it collapses to the center and fades.
Examples#
Every glyph
Step through the set. Any glyph morphs into any other because each one is drawn from the same three strokes.
menu
import { Button } from "@oration/canon/components/button";import { IconMorph } from "@oration/canon/components/icon-morph";import * as React from "react";export function Glyphs() { const [index, setIndex] = React.useState(0); const icon = glyphs[index] ?? "menu"; return ( <div className="flex flex-col items-center gap-3"> <span className="flex size-12 items-center justify-center rounded-[10px] bg-muted"> <IconMorph icon={icon} className="size-6" /> </span> <Button variant="outline" size="sm" onClick={() => setIndex((value) => (value + 1) % glyphs.length)} > Next glyph </Button> <p className="font-mono text-xs text-muted-foreground">{icon}</p> </div> );}States#
| State | Treatment |
|---|---|
| Rest | The glyph for icon. The first render never animates. |
| Morphing | When icon changes, each stroke's points travel to the new glyph on spring.moderate, and the middle stroke fades in or out. |
| Reduced motion | Reads useReducedMotion. The new glyph draws at once. |
Behavior#
- Glyphs:
menu,close,plus,minus,check,chevron-up,chevron-down,chevron-left,chevron-right,arrow-up,arrow-down,arrow-left,arrow-right. - Pass the glyph for the current state:
icon={open ? "close" : "menu"}. There is no key to manage; the strokes are fixed for the life of the icon. - Every glyph is three strokes of three points, so every path has the same shape and Motion interpolates
ddirectly. - It takes every
svgprop, soclassName="size-3.5"anddata-icon="inline-start"work as they do on a Lucide icon inside Button.
Do and don't#
Content#
- The control says its state in words:
aria-expandedon a disclosure, a label that changes from Add to list to Added.
Accessibility#
- The svg is
aria-hidden="true". Name the control around it. - Disclosures set
aria-expanded; toggles setaria-pressed; actions whose meaning changes change theiraria-label. - Under reduced motion the glyph changes at once.
Design tokens#
| Token | Used for |
|---|---|
spring.moderate | Stroke travel and fade |
24 grid, stroke 2, round caps | Matches Lucide so it sits beside other icons |
API reference#
IconMorph
A line icon that morphs between related glyphs.
Other props spread onto <svg>.
| Prop | Type | Default | Description |
|---|---|---|---|
iconRequired | "menu" | "close" | "plus" | "minus" | "check" | "chevron-up" | "chevron-down" | "chevron-left" | "chevron-right" | "arrow-up" | "arrow-down" | "arrow-left" | "arrow-right" | No default | The glyph for the current state. |
className | string | No default | On the svg, merged after shrink-0. Size it like a Lucide icon. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The set is line glyphs only. Filled icons (play, pause, a filled star) and multi-part icons can't morph and go through Icon swap.