Morph Icon
One icon, many shapes. Every glyph is exactly three SVG lines, so any icon can fluidly morph into any other.
'use client';
import { useState } from 'react';
import {
type IconName,
iconNames,
MorphIcon,
} from '@/foundations/icons/morph-icon';
import { cn } from '@/lib/utils/classnames';
export default function MorphIconPreview() {
const [icon, setIcon] = useState<IconName>('menu');
return (
<div className="flex flex-col items-center gap-6">
{/* Selected icon — big circular accent tile */}
<div className="grid size-24 place-items-center rounded-full bg-accent text-accent-foreground">
<MorphIcon icon={icon} size={36} strokeWidth={1.75} />
</div>
{/* Picker — bordered tiles, one per icon */}
<div className="grid max-w-md grid-cols-8 gap-1.5">
{iconNames.map((name) => {
const active = icon === name;
return (
<button
key={name}
type="button"
aria-label={name}
aria-pressed={active}
onClick={() => setIcon(name)}
className={cn(
'grid size-9 cursor-pointer place-items-center rounded-lg border outline-none',
'transition-colors duration-(--duration-hover) ease-out',
'focus-visible:ring-(length:--ring-width) ring-ring',
active
? 'border-accent bg-surface-overlay text-foreground ring-2 ring-accent'
: 'border-border bg-background text-foreground-secondary hover:bg-surface-overlay hover:text-foreground'
)}
>
<MorphIcon icon={name} size={16} />
</button>
);
})}
</div>
</div>
);
} Dependencies
Source Code
'use client';
import {
motion,
type Transition,
useReducedMotion,
useSpring,
} from 'motion/react';
import { useEffect, useMemo, useRef } from 'react';
import { cn } from '@/lib/utils/classnames';
interface IconLine {
x1: number;
y1: number;
x2: number;
y2: number;
opacity?: number;
}
interface IconDefinition {
lines: [IconLine, IconLine, IconLine];
rotation?: number;
/** Icons sharing a group rotate between members instead of morphing endpoints. */
group?: string;
}
type IconName =
| 'menu'
| 'cross'
| 'plus'
| 'minus'
| 'equals'
| 'asterisk'
| 'more'
| 'check'
| 'play'
| 'pause'
| 'download'
| 'upload'
| 'external'
| 'arrow-right'
| 'arrow-down'
| 'arrow-left'
| 'arrow-up'
| 'chevron-right'
| 'chevron-down'
| 'chevron-left'
| 'chevron-up'
| 'grip'
| 'slash'
| 'corner';
const CENTER = 7;
const VIEWBOX_SIZE = 14;
const collapsed: IconLine = {
x1: CENTER,
y1: CENTER,
x2: CENTER,
y2: CENTER,
opacity: 0,
};
// §10 one-off: expo-out stroke morph — not in motion.css (no shared token).
const defaultTransition: Transition = {
ease: [0.19, 1, 0.22, 1],
duration: 0.4,
};
const arrowLines: [IconLine, IconLine, IconLine] = [
{ x1: 2, y1: 7, x2: 12, y2: 7 },
{ x1: 7.5, y1: 2.5, x2: 12, y2: 7 },
{ x1: 7.5, y1: 11.5, x2: 12, y2: 7 },
];
const chevronLines: [IconLine, IconLine, IconLine] = [
{ x1: 5, y1: 2.5, x2: 9.5, y2: 7 },
{ x1: 5, y1: 11.5, x2: 9.5, y2: 7 },
collapsed,
];
const plusLines: [IconLine, IconLine, IconLine] = [
{ x1: 7, y1: 2, x2: 7, y2: 12 },
{ x1: 2, y1: 7, x2: 12, y2: 7 },
collapsed,
];
const icons: Record<IconName, IconDefinition> = {
menu: {
lines: [
{ x1: 2, y1: 3.5, x2: 12, y2: 3.5 },
{ x1: 2, y1: 7, x2: 12, y2: 7 },
{ x1: 2, y1: 10.5, x2: 12, y2: 10.5 },
],
},
cross: { lines: plusLines, rotation: 45, group: 'plus-cross' },
plus: { lines: plusLines, rotation: 0, group: 'plus-cross' },
minus: {
lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, collapsed, collapsed],
},
equals: {
lines: [
{ x1: 2, y1: 5, x2: 12, y2: 5 },
{ x1: 2, y1: 9, x2: 12, y2: 9 },
collapsed,
],
},
asterisk: {
lines: [
{ x1: 7, y1: 2, x2: 7, y2: 12 },
{ x1: 2.67, y1: 4.5, x2: 11.33, y2: 9.5 },
{ x1: 11.33, y1: 4.5, x2: 2.67, y2: 9.5 },
],
},
more: {
lines: [
{ x1: 3, y1: 7, x2: 3, y2: 7 },
{ x1: 7, y1: 7, x2: 7, y2: 7 },
{ x1: 11, y1: 7, x2: 11, y2: 7 },
],
},
check: {
lines: [
{ x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
{ x1: 5.5, y1: 11, x2: 12, y2: 3 },
collapsed,
],
},
play: {
lines: [
{ x1: 4, y1: 2.5, x2: 4, y2: 11.5 },
{ x1: 4, y1: 2.5, x2: 11.5, y2: 7 },
{ x1: 4, y1: 11.5, x2: 11.5, y2: 7 },
],
},
pause: {
lines: [
{ x1: 4, y1: 2.5, x2: 4, y2: 11.5 },
{ x1: 10, y1: 2.5, x2: 10, y2: 11.5 },
collapsed,
],
},
download: {
lines: [
{ x1: 7, y1: 2, x2: 7, y2: 10 },
{ x1: 3.5, y1: 6.5, x2: 7, y2: 10 },
{ x1: 10.5, y1: 6.5, x2: 7, y2: 10 },
],
},
upload: {
lines: [
{ x1: 7, y1: 12, x2: 7, y2: 4 },
{ x1: 3.5, y1: 7.5, x2: 7, y2: 4 },
{ x1: 10.5, y1: 7.5, x2: 7, y2: 4 },
],
},
external: {
lines: [
{ x1: 3, y1: 11, x2: 11, y2: 3 },
{ x1: 5, y1: 3, x2: 11, y2: 3 },
{ x1: 11, y1: 3, x2: 11, y2: 9 },
],
},
'arrow-right': { lines: arrowLines, rotation: 0, group: 'arrow' },
'arrow-down': { lines: arrowLines, rotation: 90, group: 'arrow' },
'arrow-left': { lines: arrowLines, rotation: 180, group: 'arrow' },
'arrow-up': { lines: arrowLines, rotation: -90, group: 'arrow' },
'chevron-right': { lines: chevronLines, rotation: 0, group: 'chevron' },
'chevron-down': { lines: chevronLines, rotation: 90, group: 'chevron' },
'chevron-left': { lines: chevronLines, rotation: 180, group: 'chevron' },
'chevron-up': { lines: chevronLines, rotation: -90, group: 'chevron' },
grip: {
lines: [
{ x1: 7, y1: 3, x2: 7, y2: 3 },
{ x1: 7, y1: 7, x2: 7, y2: 7 },
{ x1: 7, y1: 11, x2: 7, y2: 11 },
],
},
slash: {
lines: [{ x1: 11, y1: 3, x2: 3, y2: 11 }, collapsed, collapsed],
},
corner: {
lines: [
{ x1: 4, y1: 3, x2: 4, y2: 11 },
{ x1: 4, y1: 11, x2: 11, y2: 11 },
collapsed,
],
},
};
interface AnimatedLineProps {
line: IconLine;
transition: Transition;
}
const AnimatedLine = ({ line, transition }: AnimatedLineProps) => (
<motion.line
animate={{
x1: line.x1,
y1: line.y1,
x2: line.x2,
y2: line.y2,
opacity: line.opacity ?? 1,
}}
transition={transition}
strokeLinecap="round"
/>
);
interface MorphIconProps {
icon: IconName;
size?: number;
className?: string;
strokeWidth?: number;
transition?: Transition;
}
const MorphIcon = ({
icon,
size = 14,
className,
strokeWidth = 1.5,
transition = defaultTransition,
}: MorphIconProps) => {
const definition = icons[icon];
const reducedMotion = useReducedMotion() ?? false;
const prevDefinitionRef = useRef<IconDefinition>(definition);
const activeTransition = reducedMotion ? { duration: 0 } : transition;
const rotation = useSpring(definition.rotation ?? 0, activeTransition);
// Same group → rotate between members. Different group → snap rotation
// and let the line endpoints morph.
const shouldRotate = useMemo(() => {
const prev = prevDefinitionRef.current;
return prev.group && definition.group && prev.group === definition.group;
}, [definition]);
useEffect(() => {
if (shouldRotate) {
rotation.set(definition.rotation ?? 0);
} else {
rotation.jump(definition.rotation ?? 0);
}
prevDefinitionRef.current = definition;
}, [definition, shouldRotate, rotation]);
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
className={cn('shrink-0', className)}
aria-hidden="true"
>
<motion.g
style={{
rotate: rotation,
transformOrigin: 'center',
}}
>
<AnimatedLine
line={definition.lines[0]}
transition={activeTransition}
/>
<AnimatedLine
line={definition.lines[1]}
transition={activeTransition}
/>
<AnimatedLine
line={definition.lines[2]}
transition={activeTransition}
/>
</motion.g>
</svg>
);
};
const iconNames = Object.keys(icons) as IconName[];
export type { IconName, MorphIconProps };
export { iconNames, MorphIcon }; MorphIcon is a tiny universal icon system inspired by Benji’s three-line
experiments with Claude. Every shape — menu, plus, check, chevron, arrow,
play, pause — is encoded as exactly three SVG <line> segments on a 14×14
canvas. Shapes that need fewer than three visible strokes collapse the
extras to a zero-length point at the canvas center: invisible, but still
tweenable into the next shape’s endpoints.
Because every icon shares the same three-line skeleton, any icon can
morph into any other. motion.line animates each endpoint to its new
coordinates; collapsed lines fade their opacity in parallel so they appear
and disappear cleanly.
Group rotation
Some icons are the same shape rotated. The arrow set (right / down / left /
up), the chevron set, and plus ↔ cross are all marked with a group. When
you switch between members of the same group, the icon rotates rather
than morphing endpoints. When you cross groups, rotation snaps and endpoints
morph as usual. The result: chevron-right → chevron-down spins; chevron-right
→ check morphs.
Reduced motion
The component respects prefers-reduced-motion. When the user has it on,
all transitions collapse to duration: 0 — icons swap instantly, no morph.
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
icon * | - | IconName | Name of the icon to render. Changing this triggers a morph or rotation. |
size | 14 | number | Pixel size of the icon, applied to width + height on the root <svg>. |
strokeWidth | 1.5 | number | Stroke width of each line. |
transition | { ease: [0.19, 1, 0.22, 1], duration: 0.4 } | Transition | Override the motion transition. The default is an expo-out curve — slow finish, no overshoot. |
className | - | string | Additional classes on the root <svg>. |
Stroke colour follows currentColor, so wrapping the icon in any element
that sets a text colour (text-foreground, text-accent-foreground,
text-error, …) tints the icon accordingly.
The full list of names is exported as the iconNames constant — useful for
building pickers or playgrounds.
Usage
import { MorphIcon } from '@/foundations/icons/morph-icon/morph-icon';
import { useState } from 'react';
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(!open)} aria-label={open ? 'Close' : 'Open menu'}>
<MorphIcon icon={open ? 'cross' : 'menu'} size={20} />
</button>
Adding a new shape
Open morph-icon.tsx and extend the icons constant. Each entry is an
IconDefinition with three IconLine segments on the 14×14 canvas. Use the
exported collapsed line for any stroke you want to hide in this shape, and
optionally assign a group + rotation so members of the same family rotate
into each other instead of morphing.
Previous
Copy Button
Next
Roadmap