## 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 && ( )} ); } ```