{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spool",
  "type": "registry:ui",
  "title": "Spool",
  "description": "A container that changes shape to fit whatever it is showing, on a spring that carries its velocity through an interruption.",
  "registryDependencies": [
    "utils",
    "use-spring"
  ],
  "files": [
    {
      "path": "registry/loomui/spool.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useSpring, type SpringOptions } from \"@/registry/lib/use-spring\"\n\nexport interface SpoolProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"children\"\n> {\n  /** Which state is showing. */\n  value: string\n  /** One entry per state. Whatever is not `value` is not in the DOM. */\n  children: React.ReactNode\n  /** How the shape travels between states. */\n  spring?: SpringOptions\n  /** Corner radius in pixels. Kept circular through the morph. */\n  radius?: number\n  /** Milliseconds the outgoing state stays mounted for. */\n  fade?: number\n}\n\nexport interface SpoolItemProps extends React.ComponentProps<\"div\"> {\n  /** Matches the `value` on the parent. */\n  value: string\n}\n\n/** Milliseconds the outgoing state takes to leave. */\nconst EXIT_MS = 120\n/** Milliseconds a piece takes to arrive once the old state has gone. */\nconst ENTER_MS = 170\n/** Milliseconds between one piece landing and the next. */\nconst STAGGER_MS = 45\n\n/**\n * One state's contents. Sized by whatever is inside it: the shape follows the\n * content rather than the content being poured into a shape.\n */\nexport function SpoolItem({\n  value: _value,\n  className,\n  ...props\n}: SpoolItemProps) {\n  return (\n    <div\n      data-slot=\"spool-item\"\n      className={cn(\"flex items-center gap-2.5 px-4 py-2.5\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction findItem(children: React.ReactNode, value: string) {\n  let found: React.ReactElement<SpoolItemProps> | null = null\n\n  React.Children.forEach(children, (child) => {\n    if (\n      !found &&\n      React.isValidElement<SpoolItemProps>(child) &&\n      child.props.value === value\n    ) {\n      found = child\n    }\n  })\n\n  return found as React.ReactElement<SpoolItemProps> | null\n}\n\n/**\n * A shape that changes to fit what it holds.\n *\n * The size is never animated. The box is set once, in the commit that swaps the\n * contents, and the difference replays as a transform: start at the ratio\n * between old and new, spring to 1. Layout runs once per change.\n */\nexport function Spool({\n  value,\n  children,\n  spring,\n  radius = 999,\n  fade = 200,\n  className,\n  style,\n  ...props\n}: SpoolProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const wrapRef = React.useRef<HTMLDivElement>(null)\n  // The root is never laid out at its animated size, so its own offset box is\n  // always the natural size of the contents. That is what makes a remeasure\n  // mid-morph exact rather than a guess.\n  const natural = React.useRef({ width: 0, height: 0 })\n  const previous = React.useRef(value)\n  const leaveTimer = React.useRef(0)\n  const activeRef = React.useRef<HTMLDivElement>(null)\n  const leavingRef = React.useRef<HTMLDivElement>(null)\n  // The pieces inside the active state, collected once per change. Each one\n  // arrives a beat after the last, which is the difference between contents\n  // landing and contents appearing.\n  const parts = React.useRef<HTMLElement[]>([])\n  /** When this morph began, and where the shape was on the last frame. */\n  const morphAt = React.useRef(0)\n  const held = React.useRef({ sx: 1, sy: 1 })\n  const topUp = React.useRef(0)\n  // How far from 1 the scale was when this morph began. The contents are faded\n  // against the shape's progress, not against a duration, so an interruption\n  // cannot leave text showing at a size the box has not reached yet.\n  const span = React.useRef(0)\n  // The scale the morph began at. The state on its way out is sized against\n  // this, so it fills the shape at the start and shrinks with it from there.\n  const start = React.useRef({ sx: 1, sy: 1 })\n\n  const [leaving, setLeaving] = React.useState<string | null>(null)\n\n  const paint = React.useCallback(\n    ({ sx = 1, sy = 1 }: Record<string, number>) => {\n      const root = rootRef.current\n      const wrap = wrapRef.current\n      if (!root || !wrap) return\n\n      root.style.transform = `scale(${sx}, ${sy})`\n      // Divided per axis, so the corner stays a circle while the box is not a\n      // rectangle it started as.\n      root.style.borderRadius = `${radius / sx}px / ${radius / sy}px`\n      wrap.style.transform = `scale(${1 / sx}, ${1 / sy})`\n\n      held.current = { sx, sy }\n      const elapsed = performance.now() - morphAt.current\n      const gap = Math.max(Math.abs(1 - sx), Math.abs(1 - sy))\n      const progress =\n        span.current > 0 ? 1 - Math.min(gap / span.current, 1) : 1\n\n      // The state on its way out fills the shape at the start and shrinks with\n      // it, rather than being held at true size like the incoming one. When the\n      // shape narrows it is pulled in a little past that as well, eased in over\n      // the morph. Applying it on the first frame is a visible pop, since the\n      // old contents were at their own size the frame before.\n      const leaving = leavingRef.current\n      if (leaving) {\n        // One constant scale for the whole morph. Outside the counter scaled\n        // wrapper the only correction it needs is for the scale the shape\n        // started at, and that does not change while the morph runs.\n        const pull = start.current.sx > 1 ? 1 - 0.06 * progress : 1\n        leaving.style.transform = `translate(-50%, -50%) scale(${\n          pull / start.current.sx\n        }, ${1 / start.current.sy})`\n\n        const going = Math.max(0, 1 - elapsed / EXIT_MS)\n        leaving.style.opacity = `${going}`\n        leaving.style.filter = going > 0 ? `blur(${(1 - going) * 4}px)` : \"\"\n      }\n\n      const active = activeRef.current\n      if (!active) return\n\n      // Scale, blur and opacity together. Any one of the three on its own reads\n      // as a fade; all three read as something arriving. The blur stays small:\n      // a wide one spreads badly and costs more than it is worth in Safari.\n      const dress = (node: HTMLElement, t: number) => {\n        node.style.opacity = `${t}`\n        node.style.transform = `scale(${0.9 + 0.1 * t})`\n        node.style.filter = t < 1 ? `blur(${(1 - t) * 3}px)` : \"\"\n      }\n\n      // One state at a time. The old one is gone before the new one starts, so\n      // the two are never both legible at once.\n      //\n      // On a clock rather than on the shape's progress. A spring covers most of\n      // its distance in the first third, so a handoff written in progress lands\n      // almost all at once and reads as a twitch.\n      const handoff = (offset: number) =>\n        Math.max(0, Math.min((elapsed - EXIT_MS - offset) / ENTER_MS, 1))\n\n      // Growing, the contents are still wider than the shape holding them, so\n      // they wait for the room as well. Shrinking, the room is already there.\n      const growing = start.current.sx < 1\n      const room = growing\n        ? Math.max(0, Math.min((progress - 0.3) / 0.4, 1))\n        : 1\n\n      const at = (index: number) => Math.min(handoff(index * STAGGER_MS), room)\n\n      // Each piece lands a little after the one before it. Held against the\n      // shape's progress rather than a clock, so an interruption never leaves\n      // half of them arriving and half already there.\n      if (parts.current.length > 0) {\n        active.style.opacity = \"\"\n        active.style.transform = \"\"\n        active.style.filter = \"\"\n        parts.current.forEach((node, index) => dress(node, at(index)))\n        return\n      }\n\n      dress(active, at(0))\n    },\n    [radius]\n  )\n\n  const { to, set, peek, speed } = useSpring(paint, {\n    duration: 0.42,\n    bounce: 0.18,\n    ...spring,\n  })\n\n  const measure = React.useCallback(() => {\n    const root = rootRef.current\n    if (!root) return { width: 0, height: 0 }\n    return { width: root.offsetWidth, height: root.offsetHeight }\n  }, [])\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n\n    const first = natural.current\n    const isFirstPaint = first.width === 0\n\n    if (isFirstPaint || previous.current === value) {\n      natural.current = measure()\n      return\n    }\n\n    // Where the box actually is on screen this instant, and how fast it is\n    // going, both in pixels, which survive the change of scale below.\n    const at = peek()\n    const rate = speed()\n    const seen = {\n      width: first.width * (at.sx ?? 1),\n      height: first.height * (at.sy ?? 1),\n    }\n    const seenRate = {\n      width: first.width * (rate.sx ?? 0),\n      height: first.height * (rate.sy ?? 0),\n    }\n\n    // React has already swapped the contents, so this is the new natural size.\n    const item = activeRef.current?.firstElementChild\n    parts.current = item\n      ? (Array.from(item.children).filter(\n          (node) => node instanceof HTMLElement\n        ) as HTMLElement[])\n      : []\n\n    const last = measure()\n    natural.current = last\n    if (!last.width || !last.height) return\n\n    const reduced =\n      typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n    if (reduced) {\n      span.current = 0\n      start.current = { sx: 1, sy: 1 }\n      set({ sx: 1, sy: 1 })\n      return\n    }\n\n    const from = {\n      sx: seen.width / last.width,\n      sy: seen.height / last.height,\n    }\n    span.current = Math.max(Math.abs(1 - from.sx), Math.abs(1 - from.sy))\n    start.current = from\n    morphAt.current = performance.now()\n\n    set(from, {\n      sx: seenRate.width / last.width,\n      sy: seenRate.height / last.height,\n    })\n    to({ sx: 1, sy: 1 })\n\n    // The spring can settle before the contents have finished arriving, and it\n    // is the spring that drives the paint. Keep painting from the last shape\n    // until the handoff is done, or the pieces are stranded half there.\n    cancelAnimationFrame(topUp.current)\n    const window_ms =\n      EXIT_MS + ENTER_MS + parts.current.length * STAGGER_MS + 60\n    const finish = () => {\n      paint(held.current)\n      if (performance.now() - morphAt.current < window_ms) {\n        topUp.current = requestAnimationFrame(finish)\n      }\n    }\n    topUp.current = requestAnimationFrame(finish)\n  }, [value, measure, peek, speed, set, to, paint])\n\n  React.useLayoutEffect(() => {\n    if (previous.current === value) return\n\n    setLeaving(previous.current)\n    previous.current = value\n\n    window.clearTimeout(leaveTimer.current)\n    leaveTimer.current = window.setTimeout(() => setLeaving(null), fade)\n  }, [value, fade])\n\n  React.useEffect(\n    () => () => {\n      window.clearTimeout(leaveTimer.current)\n      cancelAnimationFrame(topUp.current)\n    },\n    []\n  )\n\n  // Content that changes size on its own, a font landing or a longer label,\n  // moves the natural box without any change of state, so keep it current or\n  // the next morph starts from a stale number.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root || typeof ResizeObserver === \"undefined\") return\n\n    const observer = new ResizeObserver(() => {\n      if (!leaving) natural.current = measure()\n    })\n    observer.observe(root)\n    return () => observer.disconnect()\n  }, [measure, leaving])\n\n  const active = findItem(children, value)\n  const outgoing = leaving ? findItem(children, leaving) : null\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"spool\"\n      data-value={value}\n      className={cn(\n        // `w-max` is load bearing. The whole morph is measured off the root's\n        // own offset width, so a parent that can squeeze it, a narrow grid\n        // track or a flex row, would have it measuring a box the contents have\n        // already been compressed into.\n        \"bg-background text-foreground relative inline-flex w-max overflow-hidden border shadow-lg\",\n        \"origin-center [will-change:transform]\",\n        className\n      )}\n      style={{ borderRadius: radius, ...style }}\n      {...props}\n    >\n      <div ref={wrapRef} data-slot=\"spool-wrap\" className=\"origin-center\">\n        {active ? (\n          <div key={value} ref={activeRef} data-slot=\"spool-active\">\n            {active}\n          </div>\n        ) : null}\n      </div>\n\n      {/* The state on its way out. Outside the wrapper, so nothing counter\n          scales it and it needs no correction for a scale that is still\n          changing. Pinned to the shape's centre rather than laid out in a box\n          that is already the next state's size, and `w-max` so it keeps the\n          width it was written at instead of rewrapping into whatever is left. */}\n      {outgoing ? (\n        <div\n          ref={leavingRef}\n          aria-hidden=\"true\"\n          data-slot=\"spool-leaving\"\n          className=\"pointer-events-none absolute top-1/2 left-1/2 w-max origin-center motion-reduce:hidden\"\n        >\n          {outgoing}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-out-quart": "cubic-bezier(0.165, 0.84, 0.44, 1)",
      "animate-spool-leave": "spool-leave 140ms var(--ease-out-quart) forwards"
    }
  },
  "css": {
    "@keyframes spool-leave": {
      "to": {
        "opacity": "0",
        "filter": "blur(4px)"
      }
    }
  }
}