{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "works-wheel",
  "type": "registry:ui",
  "title": "Works Wheel",
  "description": "A portfolio index that rests as a ring of cards around a title and, on the first notch of scroll, blows open into a vertical 3D drum you turn through - front card flat and full size, neighbours bowing away along an arc into hard perspective.",
  "files": [
    {
      "path": "registry/crafterui/ui/works-wheel.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// A portfolio index built as a wheel you turn.\n//\n// At rest the work sits in a ring around a title, each card tangent to the\n// circle. The first notch of scroll blows the ring open into a vertical drum:\n// the card at the front lies flat and full size, the ones above and below\n// rotate away into hard perspective and run off the top and bottom of the\n// frame. Keep turning and the drum carries the next piece round to the front.\n//\n// The whole thing is one number - `turn` - read by a single rAF pass that writes\n// transforms straight to the DOM. 0 is the ring, 1 is the drum with item 0 at\n// the front, and every whole number after that is one more item turned past.\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface WorksWheelItem {\n  /** Project name. Shown beside the front card and in the index. */\n  title: string\n  /** Cover art. Any src an <img> takes. */\n  image: string\n  /** Where the card links to. Omit for a wheel that only browses. */\n  href?: string\n}\n\nexport interface WorksWheelProps extends Omit<\n  React.ComponentPropsWithoutRef<\"section\">,\n  \"children\"\n> {\n  items: WorksWheelItem[]\n  /** Sits in the middle of the ring. @default undefined */\n  label?: string\n  /** Label on the card's hover affordance. Omit to drop it. @default undefined */\n  action?: string\n}\n\n/* Geometry. The card is measured against the stage; everything else is measured\n   against the card, so a narrow stage - where the card is capped by width, not\n   height - scales the whole wheel down with it instead of leaving a small card\n   swinging on a huge drum. The three that matter are tuned together: STEP\n   against DRUM sets how hard the neighbours rotate away, and DRUM against LENS\n   decides whether they land inside the frame or run off it. */\nconst CARD_H = 0.38 // front card height, of the stage\nconst CARD_MAX_W = 0.34 // ... but never wider than this much of the stage\nconst CARD_RATIO = 1.45 // card width / height\nconst STEP = 40 // degrees between cards on the drum\nconst DRUM = 2.22 // drum radius, in card heights - and everything below likewise\nconst LENS = 2.7 // perspective distance\nconst RING_R = 1.14 // ring radius\n/* The drum alone hangs the work on a plumb line. It isn't one: the strip curves\n   away round an arc whose centre sits off to the LEFT, so the piece at the front\n   is at the arc's near point - dead centre - and its neighbours have already\n   swung back left as well as up and down. BOW is that arc's radius; nothing else\n   makes the difference between a stack of cards and a wheel seen side on. */\nconst BOW = 1.82\nconst TITLE = 0.124 // ring label and front-card title\nconst INDEX = 0.04 // the index down the right-hand side\n/** Items either side of the front still worth drawing. Past this a card is\n    edge-on, and further round it would stack up on the vanishing point. */\nconst CULL = 1.6\n\n/** How much of a wheel-notch or a dragged pixel counts as one item. */\nconst WHEEL_UNITS = 900\nconst DRAG_UNITS = 420\n/** Quiet time after the last wheel event before the wheel settles on an item. */\nconst SETTLE = 140\n/** Fraction of the remaining distance closed each frame. 1 = no smoothing. */\nconst EASE = 0.12\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t\n\ntype Stage = { w: number; h: number }\n\nconst rad = (deg: number) => (deg * Math.PI) / 180\n\n/** How far left the arc has carried something that has turned `drumDeg` off the\n    front. Zero at the front, so the piece being read stays centred. */\nconst bowAt = (drumDeg: number, bow: number) => -bow * (1 - Math.cos(rad(drumDeg)))\n\n/** Both states in one chain: the ring terms fall away as `m` reaches the drum,\n    and the drum terms are still zero while the ring is up. The bow is applied\n    first, in the wheel's own plane, so it slides the card sideways rather than\n    turning with it - and perspective still shrinks it with distance. */\nfunction place(\n  ringDeg: number,\n  drumDeg: number,\n  ringR: number,\n  drumR: number,\n  bow: number,\n  m: number\n) {\n  return (\n    `translateX(${m * bowAt(drumDeg, bow)}px)` +\n    ` rotateZ(${(1 - m) * ringDeg}deg) translateY(${-(1 - m) * ringR}px)` +\n    ` rotateX(${m * drumDeg}deg) translateZ(${m * drumR}px)`\n  )\n}\n\nexport function WorksWheel({\n  items,\n  label = \"Works '26\",\n  action = \"View\",\n  className,\n  ...props\n}: WorksWheelProps) {\n  const stageRef = React.useRef<HTMLDivElement>(null)\n  const wheelRef = React.useRef<HTMLDivElement>(null)\n  const cardRefs = React.useRef<(HTMLElement | null)[]>([])\n  const labelRef = React.useRef<HTMLDivElement>(null)\n  const titleRef = React.useRef<HTMLDivElement>(null)\n\n  // The wheel's position, and where it is heading. Only `active` is state -\n  // everything else is written to the DOM, so turning the wheel is not a render.\n  const turn = React.useRef(0)\n  const target = React.useRef(0)\n  const [active, setActive] = React.useState(0)\n  const [stage, setStage] = React.useState<Stage>({ w: 0, h: 0 })\n\n  const count = items.length\n  const last = Math.max(count - 1, 0)\n\n  // Read after mount, not during render: the server has no matchMedia, and\n  // branching on it inline is a hydration mismatch. Reduced motion drops the\n  // easing, so the wheel lands where it is put instead of gliding there.\n  const [reduced, setReduced] = React.useState(false)\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  React.useEffect(() => {\n    const el = stageRef.current\n    if (!el) return\n    const read = () => setStage({ w: el.clientWidth, h: el.clientHeight })\n    read()\n    const ro = new ResizeObserver(read)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  const metrics = React.useMemo(() => {\n    const { w, h } = stage\n    const cardW = Math.min(h * CARD_H * CARD_RATIO, w * CARD_MAX_W)\n    const cardH = cardW / CARD_RATIO\n    const drumR = cardH * DRUM\n    const ringR = cardH * RING_R\n    // Shrink the ring's cards until the circle reads as a closed loop rather\n    // than beads on a wire, however many pieces the wheel is given.\n    const ringScale = count\n      ? clamp(((2 * Math.PI * ringR) / count) * 0.82 / (cardW || 1), 0.16, 1)\n      : 1\n    return {\n      cardW,\n      cardH,\n      ringR,\n      ringScale,\n      drumR,\n      bow: cardH * BOW,\n      depth: cardH * LENS,\n      title: cardH * TITLE,\n      index: cardH * INDEX,\n    }\n  }, [stage, count])\n\n  // One pass per frame: ease toward the target, then write every transform.\n  React.useEffect(() => {\n    if (!stage.h) return\n    let frame = 0\n    const { ringR, ringScale, drumR, bow } = metrics\n\n    const draw = () => {\n      frame = requestAnimationFrame(draw)\n      const gap = target.current - turn.current\n      if (Math.abs(gap) < 0.0005) turn.current = target.current\n      else turn.current += gap * (reduced ? 1 : EASE)\n\n      const t = turn.current\n      const m = clamp(t, 0, 1)\n      const pos = Math.max(0, t - 1)\n\n      // The drum is pulled back so its front face lands on the picture plane.\n      // That set-back has to arrive with the drum, or the ring would sit at the\n      // far side of the perspective and render at half its size.\n      if (wheelRef.current) {\n        wheelRef.current.style.transform = `translateZ(${-m * drumR}px)`\n      }\n\n      for (let i = 0; i < count; i++) {\n        const d = i - pos\n        const drumDeg = d * STEP\n        const card = cardRefs.current[i]\n        if (card) {\n          card.style.transform = place(\n            d * (360 / count),\n            drumDeg,\n            ringR,\n            drumR,\n            bow,\n            m\n          )\n          // Culled by distance, not by angle: at a full turn the far side comes\n          // back round to face us, and everything past the neighbours lands on\n          // the vanishing point in a heap.\n          card.style.opacity = m > 0.5 && Math.abs(d) > CULL ? \"0\" : \"1\"\n          card.style.zIndex = String(Math.round(100 - Math.abs(d) * 2))\n        }\n        const face = card?.firstElementChild as HTMLElement | null\n        if (face) face.style.transform = `scale(${lerp(ringScale, 1, m)})`\n      }\n\n      if (labelRef.current) labelRef.current.style.opacity = String(1 - m)\n      if (titleRef.current) titleRef.current.style.opacity = String(m)\n      const near = clamp(Math.round(pos), 0, last)\n      setActive((prev) => (prev === near ? prev : near))\n    }\n\n    frame = requestAnimationFrame(draw)\n    return () => cancelAnimationFrame(frame)\n  }, [metrics, stage.h, count, last, reduced])\n\n  const to = React.useCallback(\n    (next: number) => {\n      target.current = clamp(next, 0, last + 1)\n    },\n    [last]\n  )\n\n  // Native listener, because the wheel has to be cancellable - and it only\n  // cancels while it still has somewhere to go, so the page scrolls on at\n  // either end instead of trapping the reader.\n  React.useEffect(() => {\n    const el = stageRef.current\n    if (!el) return\n    const onWheel = (event: WheelEvent) => {\n      const next = target.current + event.deltaY / WHEEL_UNITS\n      if (next > 0 && next < last + 1) event.preventDefault()\n      to(next)\n      // A wheel gesture arrives as a burst of events with no end of its own, so\n      // the rest position is whatever notch it happened to stop on. Left there\n      // the drum sits between two cards - nothing at the front, and the pair\n      // either side of the gap both turned half away. Settle onto an item.\n      window.clearTimeout(settling.current)\n      settling.current = window.setTimeout(\n        () => to(Math.round(target.current)),\n        SETTLE\n      )\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel)\n      window.clearTimeout(settling.current)\n    }\n  }, [to, last])\n\n  const drag = React.useRef<number | null>(null)\n  const settling = React.useRef(0)\n\n  return (\n    <section\n      aria-label={label}\n      className={cn(\n        \"bg-background text-foreground relative h-full min-h-[24rem] w-full overflow-hidden select-none\",\n        className\n      )}\n      {...props}\n    >\n      <div\n        ref={stageRef}\n        tabIndex={0}\n        role=\"listbox\"\n        aria-label={label}\n        aria-activedescendant={`works-wheel-${active}`}\n        className=\"focus-visible:outline-foreground absolute inset-0 cursor-grab touch-pan-x outline-none focus-visible:outline-2 focus-visible:-outline-offset-4 active:cursor-grabbing\"\n        style={{ perspective: `${metrics.depth}px` }}\n        onPointerDown={(event) => {\n          drag.current = event.clientY\n          event.currentTarget.setPointerCapture(event.pointerId)\n        }}\n        onPointerMove={(event) => {\n          if (drag.current === null) return\n          to(target.current + (drag.current - event.clientY) / DRAG_UNITS)\n          drag.current = event.clientY\n        }}\n        onPointerUp={() => {\n          // Land on an item rather than between two.\n          drag.current = null\n          if (target.current > 1) to(Math.round(target.current))\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\") to(Math.round(target.current) + 1)\n          else if (event.key === \"ArrowUp\") to(Math.round(target.current) - 1)\n          else return\n          event.preventDefault()\n        }}\n      >\n        <div\n          ref={wheelRef}\n          className=\"absolute top-1/2 left-1/2 [transform-style:preserve-3d]\"\n        >\n          {items.map((item, i) => {\n            const Tag = (item.href ? \"a\" : \"div\") as \"a\"\n            return (\n              <React.Fragment key={item.title}>\n                <Tag\n                  id={`works-wheel-${i}`}\n                  role=\"option\"\n                  aria-selected={i === active}\n                  href={item.href}\n                  ref={(node: HTMLElement | null) => {\n                    cardRefs.current[i] = node\n                  }}\n                  className=\"group absolute [backface-visibility:hidden]\"\n                  style={{\n                    width: metrics.cardW,\n                    height: metrics.cardH,\n                    marginLeft: -metrics.cardW / 2,\n                    marginTop: -metrics.cardH / 2,\n                  }}\n                >\n                  <span className=\"bg-muted shadow-foreground/12 relative block size-full overflow-hidden rounded-lg shadow-[0_18px_40px_-18px_var(--tw-shadow-color)]\">\n                    <img\n                      src={item.image}\n                      alt={item.title}\n                      draggable={false}\n                      className=\"size-full object-cover\"\n                    />\n                    {action && item.href ? (\n                      <span className=\"bg-background/80 text-foreground pointer-events-none absolute right-3 bottom-3 flex translate-y-1 items-center gap-1 rounded-full px-2.5 py-1 text-[0.7rem] opacity-0 backdrop-blur-sm transition group-hover:translate-y-0 group-hover:opacity-100\">\n                        <svg viewBox=\"0 0 12 12\" className=\"size-2.5\" aria-hidden=\"true\">\n                          <path\n                            d=\"M3 9 9 3M4 3h5v5\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"1.4\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                        </svg>\n                        {action}\n                      </span>\n                    ) : null}\n                  </span>\n                </Tag>\n              </React.Fragment>\n            )\n          })}\n        </div>\n      </div>\n\n      {/* Ring title and front-card title trade places across the transition.\n          Type is sized off the measured stage, not vh, so the wheel keeps its\n          proportions inside a card as well as at full bleed. */}\n      <div\n        ref={labelRef}\n        className=\"pointer-events-none absolute inset-0 grid place-items-center tracking-tight\"\n        style={{ fontSize: metrics.title }}\n      >\n        {label}\n      </div>\n      <div\n        ref={titleRef}\n        className=\"pointer-events-none absolute top-1/2 left-[8%] -translate-y-1/2 tracking-tight opacity-0\"\n        style={{ fontSize: metrics.title }}\n      >\n        {items[active]?.title}\n      </div>\n\n      <ol\n        className=\"text-muted-foreground absolute top-[7.5%] right-[2.5%] text-right leading-[1.75]\"\n        style={{ fontSize: metrics.index }}\n      >\n        {items.map((item, i) => (\n          <li key={item.title}>\n            <button\n              type=\"button\"\n              onClick={() => to(i + 1)}\n              className={cn(\n                \"focus-visible:outline-foreground cursor-pointer transition-colors outline-none focus-visible:outline-1\",\n                i === active && \"text-foreground font-medium\"\n              )}\n            >\n              {item.title}\n            </button>\n          </li>\n        ))}\n      </ol>\n    </section>\n  )\n}\n"
    }
  ]
}
