## Icon Morph — Action Feedback
Play/pause, menu/close as one mechanism.
Docs: https://www.interior.dev/docs/icon-morph
Reference: https://www.interior.dev/reference/icon-morph
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/icon-morph.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useState } from "react";
import { IconMorph } from "@/components/interior/icon-morph";
export function PlayerBar() {
const [playing, setPlaying] = useState(false);
const [open, setOpen] = useState(false);
return (
setPlaying(i === 1)}
/>
setOpen(i === 1)}
/>
);
}
```
### Props
- `preset` (`"menu-close" | "play-pause" | "plus-minus" | "check-close"`) — default: `"menu-close"`
Built-in shape pair. Each preset ships its paths on a shared command signature so the geometry interpolates.
- `shapes` (`readonly MorphShape[]`)
Your own states: `{ d: string[]; rotate?: number }` per state. Two or more; the button cycles through them.
- `mode` (`"stroke" | "fill"`) — default: `preset's mode`
Whether the paths are stroked outlines or filled bodies. Play/pause is filled, the rest are stroked.
- `labels` (`readonly string[]`) — default: `preset's labels`
One per state. Becomes the accessible name, and the visible text when showLabel is set.
- `active` (`number | boolean`)
Controlled state index. A boolean maps to 0 and 1. Omit to let the component own its state.
- `defaultActive` (`number | boolean`) — default: `0`
Starting state when uncontrolled.
- `onActiveChange` (`(index: number) => void`)
Fires with the next index on activation, in both controlled and uncontrolled mode.
- `semantics` (`"label" | "pressed" | "expanded"`) — default: `"label"`
Which ARIA state the second index reports: none, aria-pressed, or aria-expanded.
- `showLabel` (`boolean`) — default: `false`
Renders the labels beside the icon. All of them share one grid cell, so the button is as wide as the longest.
- `size` (`number`) — default: `20`
Icon box in px. The 24-unit viewBox scales to it; the button stays 36px tall.
- `strokeWidth` (`number`) — default: `1.75`
Stroke mode only.
- `disabled` (`boolean`) — default: `false`
Blocks activation and the press displacement.
- `className` (`string`) — default: `""`
Appended last, so any of the button chrome can be overridden.
### Behavior notes
- Two icons crossfaded over each other draw both shapes at once through the middle of the transition; this renders one path list and interpolates its coordinates, so there is never a second icon on screen to catch.
- Every state is padded to the same slot count, and a slot a state does not use collapses to a zero-length path and fades, so the number of paths never changes mid-flight and no stroke pops into existence.
- The icon box and the label cell are reserved before the first paint — all labels stack in one grid cell — so swapping Play for Pause or Menu for Close cannot widen the button or push the row beside it.
- Activating the control mid-transition resumes the spring from the geometry currently on screen, rather than snapping back to the previous shape and replaying.
- Under prefers-reduced-motion the target geometry is applied in one frame; the icon still shows the correct state instead of being hidden or left mid-morph.
- It is a real button with aria-pressed or aria-expanded and an accessible name that changes once per state, so a screen reader announces the new state on activation and nothing repeats it.
### Source (`components/interior/icon-morph.tsx`)
```tsx
"use client";
import { useCallback, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
const INSTANT = { duration: 0 } as const;
const NUMBER = /-?\d*\.?\d+/g;
const CENTER = "12";
export type MorphShape = {
d: readonly string[];
rotate?: number;
};
export type IconMorphMode = "stroke" | "fill";
export type IconMorphPreset =
| "menu-close"
| "play-pause"
| "plus-minus"
| "check-close";
export type IconMorphSlot = {
key: number;
d: string;
visible: boolean;
};
export type IconMorphSemantics = "label" | "pressed" | "expanded";
export const iconMorphPresets: Record<
IconMorphPreset,
{ mode: IconMorphMode; labels: readonly string[]; shapes: readonly MorphShape[] }
> = {
"menu-close": {
mode: "stroke",
labels: ["Menu", "Close"],
shapes: [
{
rotate: 0,
d: ["M 4 7 L 20 7", "M 4 12 L 20 12", "M 4 17 L 20 17"],
},
{
rotate: 90,
d: ["M 6.5 6.5 L 17.5 17.5", "M 12 12 L 12 12", "M 6.5 17.5 L 17.5 6.5"],
},
],
},
"play-pause": {
mode: "fill",
labels: ["Play", "Pause"],
shapes: [
{
d: [
"M 8 5 L 14 8.5 L 14 15.5 L 8 19 Z",
"M 14 8.5 L 20 12 L 20 12 L 14 15.5 Z",
],
},
{
d: [
"M 8 5 L 11.5 5 L 11.5 19 L 8 19 Z",
"M 15 5 L 18.5 5 L 18.5 19 L 15 19 Z",
],
},
],
},
"plus-minus": {
mode: "stroke",
labels: ["Add", "Remove"],
shapes: [
{ rotate: 0, d: ["M 5 12 L 19 12", "M 12 5 L 12 19"] },
{ rotate: 180, d: ["M 5 12 L 19 12", "M 5 12 L 19 12"] },
],
},
"check-close": {
mode: "stroke",
labels: ["Confirm", "Cancel"],
shapes: [
{ d: ["M 5 12.5 L 10 17.5 L 19.5 7", "M 12 12 L 12 12 L 12 12"] },
{ d: ["M 6.5 6.5 L 12 12 L 17.5 17.5", "M 17.5 6.5 L 12 12 L 6.5 17.5"] },
],
},
};
function isCollapsed(d: string): boolean {
const nums = d.match(NUMBER);
if (!nums || nums.length < 4) return false;
return nums.every((n, i) => n === nums[i % 2]);
}
function normalize(shapes: readonly MorphShape[]): IconMorphSlot[][] {
const slots = shapes.reduce((most, s) => Math.max(most, s.d.length), 0);
return shapes.map((shape) =>
Array.from({ length: slots }, (_, i) => {
const own = shape.d[i];
const sibling = shapes.find((s) => s.d[i] !== undefined)?.d[i] ?? "";
const d = own ?? sibling.replace(NUMBER, CENTER);
return { key: i, d, visible: !isCollapsed(d) };
}),
);
}
function toIndex(value: number | boolean): number {
return typeof value === "boolean" ? (value ? 1 : 0) : Math.trunc(value);
}
export type UseIconMorphOptions = {
preset?: IconMorphPreset;
shapes?: readonly MorphShape[];
mode?: IconMorphMode;
labels?: readonly string[];
active?: number | boolean;
defaultActive?: number | boolean;
onActiveChange?: (index: number) => void;
};
export function useIconMorph({
preset = "menu-close",
shapes,
mode,
labels,
active,
defaultActive = 0,
onActiveChange,
}: UseIconMorphOptions = {}) {
const base = iconMorphPresets[preset];
const source = shapes ?? base.shapes;
const names = labels ?? base.labels;
const count = source.length;
const [internal, setInternal] = useState(() => toIndex(defaultActive));
const reduced = useReducedMotion();
const raw = active === undefined ? internal : toIndex(active);
const index = count === 0 ? 0 : Math.min(Math.max(raw, 0), count - 1);
const frames = useMemo(() => normalize(source), [source]);
const setIndex = useCallback(
(next: number) => {
if (count === 0) return;
const wrapped = ((next % count) + count) % count;
if (active === undefined) setInternal(wrapped);
onActiveChange?.(wrapped);
},
[active, count, onActiveChange],
);
const toggle = useCallback(() => setIndex(index + 1), [setIndex, index]);
return {
index,
count,
slots: frames[index] ?? [],
rotate: source[index]?.rotate ?? 0,
mode: mode ?? base.mode,
label: names[index] ?? "",
labels: names,
transition: reduced ? INSTANT : CELL,
labelTransition: reduced ? INSTANT : CROSSFADE,
setIndex,
toggle,
};
}
export type IconMorphProps = UseIconMorphOptions & {
size?: number;
strokeWidth?: number;
showLabel?: boolean;
semantics?: IconMorphSemantics;
disabled?: boolean;
className?: string;
};
export function IconMorph({
size = 20,
strokeWidth = 1.75,
showLabel = false,
semantics = "label",
disabled = false,
className = "",
...options
}: IconMorphProps) {
const {
index,
slots,
rotate,
mode,
label,
labels,
transition,
labelTransition,
toggle,
} = useIconMorph(options);
const stroked = mode === "stroke";
return (
{showLabel && (
{labels.map((text, i) => (
{text}
))}
)}
);
}
```