{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-morph",
  "type": "registry:ui",
  "title": "Icon Morph",
  "description": "One icon that turns into another by moving its own pieces, so there is never a frame with both glyphs on screen.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/icon-morph.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Which pair of shapes the icon travels between. */\nexport type IconMorphSet = \"menu\" | \"plus\" | \"play\" | \"chevron\"\n\nexport interface IconMorphProps extends Omit<\n  React.ComponentProps<\"svg\">,\n  \"children\"\n> {\n  /** Which pair of shapes to morph between. */\n  set?: IconMorphSet\n  /** `false` shows the first shape, `true` the second. */\n  active?: boolean\n  /** Milliseconds for the morph. Set to `0` to turn it off. */\n  duration?: number\n  /** Stroke weight, in viewBox units. */\n  strokeWidth?: number\n}\n\n/**\n * What each `set` travels between, and what `active` means.\n *\n * | set       | active false | active true |\n * | --------- | ------------ | ----------- |\n * | `menu`    | hamburger    | close       |\n * | `plus`    | plus         | close       |\n * | `play`    | play         | pause       |\n * | `chevron` | chevron      | tick        |\n */\n\n/**\n * Shared by every piece that moves rather than reshapes. The duration is a\n * custom property set on the root, so one prop reaches every part without\n * being threaded through them.\n *\n * `motion-reduce:transition-none` is a class rather than an inline style on\n * purpose: an inline `transition` would outrank it and the escape would not\n * work.\n */\nconst SHIFT =\n  \"transition-[transform,opacity] ease-out-quart [transition-duration:var(--icon-morph-duration)] motion-reduce:transition-none\"\n\n/**\n * SVG transforms resolve against the viewBox once `transform-box` says so.\n * Firefox and Safari have both shipped a different initial value at some\n * point, so it is stated rather than assumed.\n */\nfunction pose(\n  origin: string,\n  transform?: string,\n  opacity?: number\n): React.CSSProperties {\n  return {\n    transformBox: \"view-box\",\n    transformOrigin: origin,\n    transform: transform ?? \"none\",\n    opacity,\n  }\n}\n\n/**\n * Quartic ease out. `--ease-out-quart` is the cubic-bezier approximation of\n * this curve, so driving the tween with the curve itself keeps the reshaping\n * sets in step with the transforming ones.\n */\nfunction easeOutQuart(t: number) {\n  return 1 - (1 - t) ** 4\n}\n\nfunction usePrefersReducedMotion() {\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const read = () => setReduced(query.matches)\n    read()\n    query.addEventListener(\"change\", read)\n    return () => query.removeEventListener(\"change\", read)\n  }, [])\n\n  return reduced\n}\n\nfunction round(value: number) {\n  return Math.round(value * 1000) / 1000\n}\n\n/** Build an outline from its commands and a flat list of coordinates. */\nfunction draw(commands: readonly string[], points: readonly number[]) {\n  let at = 0\n  let d = \"\"\n\n  for (const command of commands) {\n    if (command === \"Z\") {\n      d += \"Z\"\n      continue\n    }\n    // Rounded, or a tween writes fifteen decimal places into the DOM on every\n    // frame. A 24px icon cannot see the third one.\n    d += `${command}${round(points[at])} ${round(points[at + 1])}`\n    at += 2\n  }\n\n  return d\n}\n\n// `from` and `to` are also SMIL attribute names on `path`, hence the omission.\ninterface MorphPathProps extends Omit<\n  React.ComponentProps<\"path\">,\n  \"d\" | \"from\" | \"to\"\n> {\n  /** The command letters, shared by both poses. */\n  commands: readonly string[]\n  /** Coordinates while inactive. */\n  from: readonly number[]\n  /** Coordinates while active. Same length, same order. */\n  to: readonly number[]\n  active: boolean\n  duration: number\n}\n\n/**\n * Interpolates the outline itself, one coordinate at a time.\n *\n * The CSS `d` property does this declaratively and Safari does not implement\n * it, which on iOS means every browser, since they are all WebKit underneath.\n * Left to CSS, two of the four sets would cut instead of morph on every phone\n * ever made, and only on phones, which is the kind of bug that survives a long\n * time because it never reproduces on the machine it was written on.\n *\n * So the points are walked here and written to the `d` attribute, which every\n * engine has understood since SVG shipped. It costs a paint per frame on a\n * 24px icon, which is nothing, and it is the same cost in every browser rather\n * than a morph in some and a cut in others.\n */\nfunction MorphPath({\n  commands,\n  from,\n  to,\n  active,\n  duration,\n  ...props\n}: MorphPathProps) {\n  const node = React.useRef<SVGPathElement>(null)\n  const current = React.useRef<number[]>([...(active ? to : from)])\n  const frame = React.useRef(0)\n  const reduced = usePrefersReducedMotion()\n\n  React.useEffect(() => {\n    const target = active ? to : from\n    const start = current.current.slice()\n\n    const paint = (points: number[]) => {\n      current.current = points\n      node.current?.setAttribute(\"d\", draw(commands, points))\n    }\n\n    if (reduced || duration <= 0) {\n      paint([...target])\n      return\n    }\n\n    const began = performance.now()\n    const step = (now: number) => {\n      const elapsed = Math.min((now - began) / duration, 1)\n      const eased = easeOutQuart(elapsed)\n\n      paint(start.map((value, i) => value + (target[i] - value) * eased))\n\n      if (elapsed < 1) frame.current = requestAnimationFrame(step)\n    }\n\n    frame.current = requestAnimationFrame(step)\n    return () => cancelAnimationFrame(frame.current)\n  }, [active, commands, duration, from, reduced, to])\n\n  return <path ref={node} d={draw(commands, current.current)} {...props} />\n}\n\ninterface PartProps {\n  active: boolean\n  duration: number\n}\n\n/** Three bars. The middle one is not needed by an X, so it leaves. */\nfunction Menu({ active }: PartProps) {\n  return (\n    <>\n      <path\n        d=\"M4 6h16\"\n        className={SHIFT}\n        style={pose(\n          \"12px 6px\",\n          active ? \"translateY(6px) rotate(45deg)\" : undefined\n        )}\n      />\n      <path\n        d=\"M4 12h16\"\n        className={SHIFT}\n        style={pose(\n          \"12px 12px\",\n          active ? \"scaleX(0.4)\" : undefined,\n          active ? 0 : 1\n        )}\n      />\n      <path\n        d=\"M4 18h16\"\n        className={SHIFT}\n        style={pose(\n          \"12px 18px\",\n          active ? \"translateY(-6px) rotate(-45deg)\" : undefined\n        )}\n      />\n    </>\n  )\n}\n\n/** A plus is an X that has not been turned yet. Same two bars throughout. */\nfunction Plus({ active }: PartProps) {\n  const turn = active ? \"rotate(45deg) scale(0.86)\" : undefined\n\n  return (\n    <>\n      <path d=\"M12 5v14\" className={SHIFT} style={pose(\"12px 12px\", turn)} />\n      <path d=\"M5 12h14\" className={SHIFT} style={pose(\"12px 12px\", turn)} />\n    </>\n  )\n}\n\n/**\n * The triangle is cut down the middle so both states are two four-point\n * quadrilaterals, which is what lets the coordinates correspond. The right\n * half of the triangle is a quad with its two right-hand points on top of each\n * other, so it reads as the tip.\n *\n * Both halves live in one path, and that is not a tidiness choice. As two\n * elements the shared edge down the middle is a boundary each of them\n * antialiases against, and `currentColor` is rarely fully opaque, so the seam\n * shows until the triangle reads as the pause bars in disguise. One path is\n * one fill: the halves union and the join disappears.\n *\n * For the same reason there is no stroke. A stroke follows every edge of every\n * subpath, the two interior ones included, and paints them over a fill that is\n * already there.\n */\nconst BARS = [\"M\", \"L\", \"L\", \"L\", \"Z\", \"M\", \"L\", \"L\", \"L\", \"Z\"] as const\n// prettier-ignore\nconst PLAY = [\n  7.5, 4.5, 13.25, 8.25, 13.25, 15.75, 7.5, 19.5,\n  13.25, 8.25, 19, 12, 19, 12, 13.25, 15.75,\n] as const\n// prettier-ignore\nconst PAUSE = [\n  7, 4.5, 10.5, 4.5, 10.5, 19.5, 7, 19.5,\n  13.5, 4.5, 17, 4.5, 17, 19.5, 13.5, 19.5,\n] as const\n\nfunction Play({ active, duration }: PartProps) {\n  return (\n    <MorphPath\n      commands={BARS}\n      from={PLAY}\n      to={PAUSE}\n      active={active}\n      duration={duration}\n      fill=\"currentColor\"\n      stroke=\"none\"\n    />\n  )\n}\n\n/** Three points in both states, so one path covers the whole journey. */\nconst ARROW = [\"M\", \"L\", \"L\"] as const\nconst CHEVRON = [9, 5, 16, 12, 9, 19] as const\nconst TICK = [4, 12, 10, 18, 20, 6] as const\n\nfunction Chevron({ active, duration }: PartProps) {\n  return (\n    <MorphPath\n      commands={ARROW}\n      from={CHEVRON}\n      to={TICK}\n      active={active}\n      duration={duration}\n    />\n  )\n}\n\nconst SETS: Record<IconMorphSet, (props: PartProps) => React.ReactNode> = {\n  menu: Menu,\n  plus: Plus,\n  play: Play,\n  chevron: Chevron,\n}\n\n/**\n * One icon that changes into another. The pieces are shared between the two\n * shapes and travel, so there is never a frame with both icons on screen at\n * once. Two legible glyphs on top of each other reads as a fault, which is\n * what a crossfade gives you.\n *\n * The icon is decorative by default. Put the label on whatever wraps it.\n */\nexport function IconMorph({\n  set = \"menu\",\n  active = false,\n  duration = 220,\n  strokeWidth = 2,\n  className,\n  style,\n  ...props\n}: IconMorphProps) {\n  const Parts = SETS[set]\n\n  return (\n    <svg\n      data-slot=\"icon-morph\"\n      data-active={active}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className={cn(\"size-6 shrink-0\", className)}\n      style={\n        {\n          \"--icon-morph-duration\": `${duration}ms`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      <Parts active={active} duration={duration} />\n    </svg>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-out-quart": "cubic-bezier(0.165, 0.84, 0.44, 1)"
    }
  }
}