{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"icon-morph","type":"registry:ui","title":"Icon Morph","description":"Play/pause, menu/close as one mechanism.","dependencies":["motion"],"categories":["action feedback"],"docs":"https://www.interior.dev/docs/icon-morph","files":[{"path":"registry/interior/icon-morph.tsx","type":"registry:ui","target":"components/interior/icon-morph.tsx","content":"\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst NUMBER = /-?\\d*\\.?\\d+/g;\nconst CENTER = \"12\";\n\nexport type MorphShape = {\n  d: readonly string[];\n  rotate?: number;\n};\n\nexport type IconMorphMode = \"stroke\" | \"fill\";\n\nexport type IconMorphPreset =\n  | \"menu-close\"\n  | \"play-pause\"\n  | \"plus-minus\"\n  | \"check-close\";\n\nexport type IconMorphSlot = {\n  key: number;\n  d: string;\n  visible: boolean;\n};\n\nexport type IconMorphSemantics = \"label\" | \"pressed\" | \"expanded\";\n\nexport const iconMorphPresets: Record<\n  IconMorphPreset,\n  { mode: IconMorphMode; labels: readonly string[]; shapes: readonly MorphShape[] }\n> = {\n  \"menu-close\": {\n    mode: \"stroke\",\n    labels: [\"Menu\", \"Close\"],\n    shapes: [\n      {\n        rotate: 0,\n        d: [\"M 4 7 L 20 7\", \"M 4 12 L 20 12\", \"M 4 17 L 20 17\"],\n      },\n      {\n        rotate: 90,\n        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\"],\n      },\n    ],\n  },\n  \"play-pause\": {\n    mode: \"fill\",\n    labels: [\"Play\", \"Pause\"],\n    shapes: [\n      {\n        d: [\n          \"M 8 5 L 14 8.5 L 14 15.5 L 8 19 Z\",\n          \"M 14 8.5 L 20 12 L 20 12 L 14 15.5 Z\",\n        ],\n      },\n      {\n        d: [\n          \"M 8 5 L 11.5 5 L 11.5 19 L 8 19 Z\",\n          \"M 15 5 L 18.5 5 L 18.5 19 L 15 19 Z\",\n        ],\n      },\n    ],\n  },\n  \"plus-minus\": {\n    mode: \"stroke\",\n    labels: [\"Add\", \"Remove\"],\n    shapes: [\n      { rotate: 0, d: [\"M 5 12 L 19 12\", \"M 12 5 L 12 19\"] },\n      { rotate: 180, d: [\"M 5 12 L 19 12\", \"M 5 12 L 19 12\"] },\n    ],\n  },\n  \"check-close\": {\n    mode: \"stroke\",\n    labels: [\"Confirm\", \"Cancel\"],\n    shapes: [\n      { d: [\"M 5 12.5 L 10 17.5 L 19.5 7\", \"M 12 12 L 12 12 L 12 12\"] },\n      { 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\"] },\n    ],\n  },\n};\n\nfunction isCollapsed(d: string): boolean {\n  const nums = d.match(NUMBER);\n  if (!nums || nums.length < 4) return false;\n  return nums.every((n, i) => n === nums[i % 2]);\n}\n\nfunction normalize(shapes: readonly MorphShape[]): IconMorphSlot[][] {\n  const slots = shapes.reduce((most, s) => Math.max(most, s.d.length), 0);\n\n  return shapes.map((shape) =>\n    Array.from({ length: slots }, (_, i) => {\n      const own = shape.d[i];\n      const sibling = shapes.find((s) => s.d[i] !== undefined)?.d[i] ?? \"\";\n      const d = own ?? sibling.replace(NUMBER, CENTER);\n      return { key: i, d, visible: !isCollapsed(d) };\n    }),\n  );\n}\n\nfunction toIndex(value: number | boolean): number {\n  return typeof value === \"boolean\" ? (value ? 1 : 0) : Math.trunc(value);\n}\n\nexport type UseIconMorphOptions = {\n  preset?: IconMorphPreset;\n  shapes?: readonly MorphShape[];\n  mode?: IconMorphMode;\n  labels?: readonly string[];\n  active?: number | boolean;\n  defaultActive?: number | boolean;\n  onActiveChange?: (index: number) => void;\n};\n\nexport function useIconMorph({\n  preset = \"menu-close\",\n  shapes,\n  mode,\n  labels,\n  active,\n  defaultActive = 0,\n  onActiveChange,\n}: UseIconMorphOptions = {}) {\n  const base = iconMorphPresets[preset];\n  const source = shapes ?? base.shapes;\n  const names = labels ?? base.labels;\n  const count = source.length;\n\n  const [internal, setInternal] = useState(() => toIndex(defaultActive));\n  const reduced = useReducedMotion();\n\n  const raw = active === undefined ? internal : toIndex(active);\n  const index = count === 0 ? 0 : Math.min(Math.max(raw, 0), count - 1);\n\n  const frames = useMemo(() => normalize(source), [source]);\n\n  const setIndex = useCallback(\n    (next: number) => {\n      if (count === 0) return;\n      const wrapped = ((next % count) + count) % count;\n      if (active === undefined) setInternal(wrapped);\n      onActiveChange?.(wrapped);\n    },\n    [active, count, onActiveChange],\n  );\n\n  const toggle = useCallback(() => setIndex(index + 1), [setIndex, index]);\n\n  return {\n    index,\n    count,\n    slots: frames[index] ?? [],\n    rotate: source[index]?.rotate ?? 0,\n    mode: mode ?? base.mode,\n    label: names[index] ?? \"\",\n    labels: names,\n    transition: reduced ? INSTANT : CELL,\n    labelTransition: reduced ? INSTANT : CROSSFADE,\n    setIndex,\n    toggle,\n  };\n}\n\nexport type IconMorphProps = UseIconMorphOptions & {\n  size?: number;\n  strokeWidth?: number;\n  showLabel?: boolean;\n  semantics?: IconMorphSemantics;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function IconMorph({\n  size = 20,\n  strokeWidth = 1.75,\n  showLabel = false,\n  semantics = \"label\",\n  disabled = false,\n  className = \"\",\n  ...options\n}: IconMorphProps) {\n  const {\n    index,\n    slots,\n    rotate,\n    mode,\n    label,\n    labels,\n    transition,\n    labelTransition,\n    toggle,\n  } = useIconMorph(options);\n\n  const stroked = mode === \"stroke\";\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      onClick={toggle}\n      aria-label={label}\n      aria-pressed={semantics === \"pressed\" ? index === 1 : undefined}\n      aria-expanded={semantics === \"expanded\" ? index === 1 : undefined}\n      whileTap={disabled ? undefined : { y: 1 }}\n      transition={transition}\n      className={`inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 rounded-[9px] border border-stone-200 bg-white text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-white/30 ${\n        showLabel ? \"px-3\" : \"w-9\"\n      } ${className}`}\n      style={{ touchAction: \"manipulation\" }}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        initial={false}\n        animate={{ rotate }}\n        transition={transition}\n        className=\"grid shrink-0 place-items-center\"\n        style={{ width: size, height: size }}\n      >\n        <svg\n          viewBox=\"0 0 24 24\"\n          width={size}\n          height={size}\n          focusable=\"false\"\n          fill={stroked ? \"none\" : \"currentColor\"}\n          stroke={stroked ? \"currentColor\" : \"none\"}\n          strokeWidth={stroked ? strokeWidth : undefined}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          style={{ display: \"block\" }}\n        >\n          {slots.map((slot) => (\n            <motion.path\n              key={slot.key}\n              initial={false}\n              animate={{ d: slot.d, opacity: slot.visible ? 1 : 0 }}\n              transition={transition}\n            />\n          ))}\n        </svg>\n      </motion.span>\n\n      {showLabel && (\n        <span aria-hidden=\"true\" className=\"grid\">\n          {labels.map((text, i) => (\n            <motion.span\n              key={i}\n              initial={false}\n              animate={{\n                opacity: i === index ? 1 : 0,\n                y: i === index ? 0 : i < index ? -3 : 3,\n              }}\n              transition={labelTransition}\n              className=\"col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {text}\n            </motion.span>\n          ))}\n        </span>\n      )}\n    </motion.button>\n  );\n}\n"}]}