Skip to content

Icon morph

A line icon whose strokes move into a related glyph: menu to close, plus to minus, a chevron flipping.

Status
Beta
Level
Atom
Category
Content
Adoption
Not used yet
import { IconMorph } from "@oration/canon/components/icon-morph";
packages/canon/src/components/icon-morph.tsx
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

A label or icon that changes while people watch changes in place: icons through Icon swap or Icon morph, short labels through Text swap, numbers through Animated number. It never snaps with a plain conditional.

The Three Springs Rule

Strokes move on spring.moderate, critically damped so the shape lands exactly with no overshoot.

Anatomy#

  1. Canvas. A Lucide-compatible svg: 24 viewBox, currentColor stroke, 2px, round caps and joins, aria-hidden="true".
  2. 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.
  3. 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#

States
StateTreatment
RestThe glyph for icon. The first render never animates.
MorphingWhen icon changes, each stroke's points travel to the new glyph on spring.moderate, and the middle stroke fades in or out.
Reduced motionReads 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 d directly.
  • It takes every svg prop, so className="size-3.5" and data-icon="inline-start" work as they do on a Lucide icon inside Button.

Do and don't#

Do. Morph between two states of one object: plus to close on a filter toggle.
Don't. Snap a plus to a close with a plain conditional. The control appears to be replaced rather than to change.
Do. Swap between two different objects with Icon swap: copy to check.
Don't. Force unrelated meanings into the morph set, like an arrow and a minus for play and pause.

Content#

  • The control says its state in words: aria-expanded on 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 set aria-pressed; actions whose meaning changes change their aria-label.
  • Under reduced motion the glyph changes at once.

Design tokens#

Design tokens
TokenUsed for
spring.moderateStroke travel and fade
24 grid, stroke 2, round capsMatches Lucide so it sits beside other icons

API reference#

IconMorph

A line icon that morphs between related glyphs.

Other props spread onto <svg>.

Props of IconMorph
PropTypeDefaultDescription
iconRequired"menu" | "close" | "plus" | "minus" | "check" | "chevron-up" | "chevron-down" | "chevron-left" | "chevron-right" | "arrow-up" | "arrow-down" | "arrow-left" | "arrow-right"No defaultThe glyph for the current state.
classNamestringNo defaultOn 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.