{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"icon-morph","type":"registry:component","title":"Icon morph","description":"Icons reshape themselves between states instead of swapping.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/icon-morph.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Motion interpolates `d` like any other complex string: it pulls out every\n// number, mixes each one with the number in the same position in the target,\n// and writes them into the target string's template. That only works when both\n// paths have the same commands in the same order with the same count of\n// numbers: otherwise numbers pair up with the wrong commands (a garbled\n// shape), or Motion finds no pairing at all and snaps to the new path. Every\n// pair below is written to that shared shape, with collapsed or duplicated\n// points standing in for geometry one state lacks.\nexport type MorphShape = {\n  off: string;\n  on: string;\n  // Degrees the whole glyph turns on the way to `on`.\n  rotate?: number;\n  filled?: boolean;\n};\n\nexport const SHAPES = {\n  // The play triangle is cut into two quads at x=13 that each become a bar.\n  // The triangle sits right of the box center, because its visual weight is\n  // at its wide end.\n  playPause: {\n    off: \"M7.5 5L13 8.5L13 15.5L7.5 19Z M13 8.5L18.5 12L18.5 12L13 15.5Z\",\n    on: \"M6.5 5L10.5 5L10.5 19L6.5 19Z M13.5 5L17.5 5L17.5 19L13.5 19Z\",\n    filled: true,\n  },\n  // The middle bar folds onto the second diagonal, so the X keeps three\n  // strokes without a stray dot where a collapsed line would sit.\n  menuClose: {\n    off: \"M4.5 7L19.5 7 M4.5 12L19.5 12 M4.5 17L19.5 17\",\n    on: \"M6.5 6.5L17.5 17.5 M6.5 17.5L17.5 6.5 M6.5 17.5L17.5 6.5\",\n    // A quarter turn lands the X on itself, so the spin reads as the lines\n    // swinging into place rather than the icon changing orientation.\n    rotate: 90,\n  },\n  // The vertical bar lies down onto the horizontal one. A half turn keeps\n  // the minus horizontal where a quarter turn would stand it upright.\n  plusMinus: {\n    off: \"M5 12L19 12 M12 5L12 19\",\n    on: \"M5 12L19 12 M5 12L19 12\",\n    rotate: 180,\n  },\n  // The shaft shrinks into the short leg of the check, which the head also\n  // traces, so the two overlap once settled.\n  sendSent: {\n    off: \"M5 12L19 12 M13 6L19 12L13 18\",\n    on: \"M5 12.5L9.5 17 M5 12.5L9.5 17L19 7\",\n  },\n} satisfies Record<string, MorphShape>;\n\n// Critically damped: an icon that overshoots its own outline looks broken.\nconst MORPH = { type: \"spring\", visualDuration: 0.3, bounce: 0 } as const;\n// The turn gets a little give, which is what makes it feel physical.\nconst TURN = { type: \"spring\", visualDuration: 0.35, bounce: 0.2 } as const;\nconst FADE = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const;\n\nexport function IconMorphButton({\n  shape,\n  label,\n  captions,\n  pressed: controlled,\n  defaultPressed = false,\n  onPressedChange,\n  className,\n}: {\n  shape: MorphShape;\n  // The accessible name stays fixed and aria-pressed carries the state, so a\n  // screen reader never hears a name and a state that contradict each other\n  // (\"Pause, pressed\"). Matching the off caption keeps voice control working.\n  label: string;\n  // Visible text for each state, cross-faded as the icon morphs.\n  captions?: [off: string, on: string];\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [uncontrolled, setUncontrolled] = useState(defaultPressed);\n  const pressed = controlled ?? uncontrolled;\n\n  const toggle = () => {\n    const next = !pressed;\n    if (controlled === undefined) setUncontrolled(next);\n    onPressedChange?.(next);\n  };\n\n  return (\n    <div className={cn(\"flex flex-col items-center gap-2\", className)}>\n      <button\n        type=\"button\"\n        aria-label={label}\n        aria-pressed={pressed}\n        onClick={toggle}\n        className=\"flex size-11 touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden transition-[scale,background-color] duration-150 ease-out select-none hover:bg-border/60 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[background-color]\"\n      >\n        <motion.svg\n          viewBox=\"0 0 24 24\"\n          className=\"size-[22px]\"\n          fill={shape.filled ? \"currentColor\" : \"none\"}\n          stroke=\"currentColor\"\n          strokeWidth={1.75}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n          initial={false}\n          animate={{\n            rotate: pressed && !reduceMotion ? (shape.rotate ?? 0) : 0,\n          }}\n          transition={TURN}\n        >\n          <motion.path\n            initial={false}\n            animate={{ d: pressed ? shape.on : shape.off }}\n            transition={reduceMotion ? FADE : MORPH}\n          />\n        </motion.svg>\n      </button>\n      {captions && (\n        // Both captions share one grid cell, so the column never changes\n        // width as they swap.\n        <span aria-hidden className=\"grid text-[13px] font-medium text-muted-foreground\">\n          {captions.map((caption, i) => {\n            const visible = pressed === (i === 1);\n            return (\n              <span\n                key={caption}\n                className={cn(\n                  \"col-start-1 row-start-1 text-center transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n                  // Leaves faster than it arrives, so the old word never\n                  // lingers over the new one.\n                  visible\n                    ? \"translate-y-0 opacity-100 blur-[0px] duration-200\"\n                    : \"translate-y-0.5 opacity-0 blur-[4px] duration-100 motion-reduce:translate-y-0 motion-reduce:blur-[0px]\",\n                )}\n              >\n                {caption}\n              </span>\n            );\n          })}\n        </span>\n      )}\n    </div>\n  );\n}\n\nconst DEMO = [\n  { shape: SHAPES.playPause, label: \"Play\", captions: [\"Play\", \"Pause\"] },\n  { shape: SHAPES.menuClose, label: \"Menu\", captions: [\"Menu\", \"Close\"] },\n  {\n    shape: SHAPES.plusMinus,\n    label: \"Expand\",\n    captions: [\"Expand\", \"Collapse\"],\n  },\n  { shape: SHAPES.sendSent, label: \"Send\", captions: [\"Send\", \"Sent\"] },\n] as const;\n\nexport default function IconMorphDemo() {\n  return (\n    <div className=\"grid w-[min(360px,100%)] grid-cols-4 gap-2\">\n      {DEMO.map((item) => (\n        <IconMorphButton\n          key={item.label}\n          shape={item.shape}\n          label={item.label}\n          captions={[item.captions[0], item.captions[1]]}\n        />\n      ))}\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/icon-morph"}