"use client";
import * as React from "react";
import { motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export type MorphIconName = "play-pause" | "menu-close" | "plus-minus" | "chevron" | "heart";
export interface MorphIconButtonProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Built-in glyph pair. Ignored when both `from` and `to` are given. */
icon?: MorphIconName;
/** Custom inactive path (24×24 viewBox). Must have the same commands and number count as `to`. */
from?: string;
/** Custom active path (24×24 viewBox). */
to?: string;
/** How custom paths are painted. */
paint?: "stroke" | "fill";
/** Controlled state. */
active?: boolean;
/** Initial state when uncontrolled. */
defaultActive?: boolean;
onActiveChange?: (active: boolean) => void;
/**
* Accessible name while inactive. If `activeLabel` differs, the name switches with the state
* (Play → Pause) and aria-pressed is omitted; otherwise the button is a toggle with aria-pressed.
*/
label?: string;
/** Accessible name while active. */
activeLabel?: string;
/** Spring physics, or a fixed-duration tween. */
transition?: "spring" | "tween";
/** Spring used when `transition="spring"`. */
spring?: Spring;
/** Tween duration in ms when `transition="tween"`. */
duration?: number;
/** Extra rotation of the glyph in the active state, in degrees. */
rotate?: number;
/** Button size in px. */
size?: number;
/** Stroke width for stroked glyphs (in 24px viewBox units). */
strokeWidth?: number;
/** Surface style. */
variant?: "outline" | "solid" | "ghost";
/** Active tint (outline / ghost), fill (solid) and focus ring. */
accent?: string;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** Command letters + number count. Two paths interpolate cleanly only when these match. */
function signature(d: string) {
const commands = (d.match(/[a-df-z]/gi) ?? []).join("");
const numbers = (d.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []).length;
return `${commands}:${numbers}`;
}
/** Heart path scaled around its centre, so outline and filled hearts share one structure. */
function heart(scale: number) {
const pts = [
[12, 20.5], [12, 20.5], [3.5, 15.5], [3.5, 9.25], [3.5, 6.5], [5.6, 4.5], [8.1, 4.5], [9.75, 4.5], [11.2, 5.4], [12, 6.8],
[12.8, 5.4], [14.25, 4.5], [15.9, 4.5], [18.4, 4.5], [20.5, 6.5], [20.5, 9.25], [20.5, 15.5], [12, 20.5], [12, 20.5],
].map(([x, y]) => `${(12 + (x - 12) * scale).toFixed(2)} ${(12.5 + (y - 12.5) * scale).toFixed(2)}`);
const [m, ...c] = pts;
const curves = [];
for (let i = 0; i < c.length; i += 3) curves.push(`C${c[i]} ${c[i + 1]} ${c[i + 2]}`);
return `M${m} ${curves.join(" ")}Z`;
}
interface IconDef {
from: string;
to: string;
paint: "stroke" | "fill";
/** Fill fades in when active (heart). */
fillActive?: boolean;
label: string;
activeLabel: string;
}
export const MORPH_ICONS: Record<MorphIconName, IconDef> = {
// The play triangle is split into two quads that line up with the two pause bars.
"play-pause": {
from: "M7 4.5L13 8.25L13 15.75L7 19.5Z M13 8.25L19 12L19 12L13 15.75Z",
to: "M6 5L10 5L10 19L6 19Z M14 5L18 5L18 19L14 19Z",
paint: "fill",
label: "Play",
activeLabel: "Pause",
},
// The middle bar folds onto the second diagonal of the cross.
"menu-close": {
from: "M4 7L20 7 M4 12L20 12 M4 17L20 17",
to: "M6 6L18 18 M6 18L18 6 M6 18L18 6",
paint: "stroke",
label: "Open menu",
activeLabel: "Close menu",
},
"plus-minus": {
from: "M5 12L19 12 M12 5L12 19",
to: "M5 12L19 12 M5 12L19 12",
paint: "stroke",
label: "Expand",
activeLabel: "Collapse",
},
chevron: {
from: "M6 9L12 15L18 9",
to: "M6 15L12 9L18 15",
paint: "stroke",
label: "Show more",
activeLabel: "Show less",
},
heart: {
from: heart(1),
to: heart(1.08),
paint: "stroke",
fillActive: true,
label: "Like",
activeLabel: "Like",
},
};
const SURFACE = {
outline:
"border border-zinc-900/12 bg-white hover:border-zinc-900/25 dark:border-white/12 dark:bg-white/[0.04] dark:hover:border-white/25",
ghost: "border border-transparent hover:bg-zinc-900/[0.06] dark:hover:bg-white/[0.07]",
solid: "border border-transparent bg-[var(--mi-accent)] hover:brightness-105",
} as const;
const INK = {
outline: "text-zinc-800 dark:text-zinc-200",
ghost: "text-zinc-700 dark:text-zinc-300",
solid: "text-[#0B0D0F]",
} as const;
// Active outline / ghost glyphs take the accent, mixed with the text colour so pale accents stay legible.
const TINT = "text-[color-mix(in_oklab,var(--mi-accent)_82%,#18181b)] dark:text-[color-mix(in_oklab,var(--mi-accent)_88%,white)]";
/**
* An icon button whose glyph morphs between two states by interpolating SVG path data. Built-in
* pairs share command structure point for point; custom `from` / `to` paths must too (otherwise the
* glyphs crossfade instead).
*/
export const MorphIconButton = React.forwardRef<HTMLButtonElement, MorphIconButtonProps>(function MorphIconButton(
{
icon = "play-pause",
from,
to,
paint,
active: activeProp,
defaultActive = false,
onActiveChange,
label,
activeLabel,
transition = "spring",
spring = { stiffness: 320, damping: 26, mass: 0.8 },
duration = 320,
rotate = 0,
size = 44,
strokeWidth = 1.75,
variant = "outline",
accent = "#FF7448",
className = "",
style,
onClick,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState(defaultActive);
const active = activeProp ?? internal;
const builtIn = MORPH_ICONS[icon] ?? MORPH_ICONS["play-pause"];
const custom = from !== undefined && to !== undefined;
const def: IconDef = custom
? { from, to, paint: paint ?? "stroke", label: label ?? "Toggle", activeLabel: activeLabel ?? label ?? "Toggle" }
: { ...builtIn, paint: paint ?? builtIn.paint };
const inactiveName = label ?? def.label;
const activeName = activeLabel ?? (label !== undefined ? label : def.activeLabel);
const labelSwitches = activeName !== inactiveName;
const morphable = signature(def.from) === signature(def.to);
const motionTransition = reduceMotion
? { duration: 0 }
: transition === "tween"
? { duration: duration / 1000, ease: [0.65, 0, 0.35, 1] as const }
: { type: "spring" as const, ...spring };
const stroked = def.paint === "stroke";
const pathProps = {
fill: "currentColor",
stroke: "currentColor",
strokeWidth: stroked ? strokeWidth : 0.75,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
const fillOpacity = stroked ? (def.fillActive && active ? 1 : 0) : 1;
const tinted = variant !== "solid" && active;
return (
<button
ref={ref}
type="button"
aria-label={active ? activeName : inactiveName}
aria-pressed={labelSwitches ? undefined : active}
data-active={active || undefined}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
const next = !active;
if (activeProp === undefined) setInternal(next);
onActiveChange?.(next);
}}
className={`inline-flex shrink-0 items-center justify-center rounded-full outline-hidden transition-[background-color,border-color,color,filter] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--mi-accent)] disabled:cursor-not-allowed disabled:opacity-45 ${SURFACE[variant] ?? SURFACE.outline} ${
tinted ? TINT : (INK[variant] ?? INK.outline)
} ${className}`}
style={{ width: size, height: size, ["--mi-accent" as string]: accent, ...style }}
{...props}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="overflow-visible" style={{ width: size * 0.5, height: size * 0.5 }}>
<motion.g
initial={false}
animate={{ rotate: active ? rotate : 0 }}
transition={motionTransition}
style={{ transformBox: "view-box", transformOrigin: "12px 12px" }}
>
{morphable ? (
<motion.path
initial={false}
animate={{ d: active ? def.to : def.from, fillOpacity }}
transition={{ ...motionTransition, fillOpacity: { duration: reduceMotion ? 0 : 0.18 } }}
{...pathProps}
/>
) : (
// Mismatched custom paths cannot interpolate: crossfade them instead.
<>
<motion.path d={def.from} initial={false} animate={{ opacity: active ? 0 : 1 }} transition={{ duration: reduceMotion ? 0 : 0.18 }} {...pathProps} fillOpacity={fillOpacity} />
<motion.path d={def.to} initial={false} animate={{ opacity: active ? 1 : 0 }} transition={{ duration: reduceMotion ? 0 : 0.18 }} {...pathProps} fillOpacity={fillOpacity} />
</>
)}
</motion.g>
</svg>
</button>
);
});