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