{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halo-reel",
  "type": "registry:ui",
  "title": "Halo Reel",
  "description": "Image cards riding an ellipse — cos θ places each card, sizes it and stacks it, so the near side of the ring is large and the far side small. It turns on its own, pauses under the pointer, and you can drag it round.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/halo-reel.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── Halo Reel ───────────────────────────────────────────────────\n * Cards ride an ellipse. Card i sits at θ = i·step + rotation on an\n * ellipse of radii (rx, ry):\n *\n *   x = rx·cos θ      y = ry·sin θ      scale = min + (1−min)·(cos θ + 1)/2\n *\n * cos θ does all the work: it places the card, sizes it, and — through the\n * scale — stacks it. One number driving three properties is why a card that\n * looks nearer *is* nearer; the stacking can never disagree with the\n * perspective.\n *\n * One `rotation` motion value drives the whole ring. Every card derives its\n * transform from it through `useTransform`, so a spin never re-renders React\n * — the ring turns at 60 fps whether it is autoplaying, being dragged, or\n * settling onto a snap.\n * ─────────────────────────────────────────────────────────────── */\n\nexport type HaloReelItem = {\n  /** Image for the card. Omit it and the card falls back to the text face. */\n  src?: string;\n  alt?: string;\n  /** Text face, used when there is no `src`. Both default to the shadcn\n   *  `card` tokens, so a text card themes itself in light and dark. */\n  bgColor?: string;\n  textColor?: string;\n  title?: string;\n  subtitle?: string;\n};\n\nexport interface HaloReelProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  items: HaloReelItem[];\n  /** Card width in px at the front of the ring. @default 130 */\n  cardWidth?: number;\n  /** Card height in px at the front of the ring. @default 180 */\n  cardHeight?: number;\n  /** Scale of the card at the far side of the ring. @default 0.4 */\n  minScale?: number;\n  /** Horizontal radius as a fraction of the stage width. @default 0.45 */\n  radiusXRatio?: number;\n  /** Where the ellipse is centred across the stage. `0` pins it to the left\n   *  edge, so the far half of the ring is clipped away. @default 0 */\n  centerXRatio?: number;\n  /** Vertical radius as a fraction of the stage height. @default 0.36 */\n  radiusYRatio?: number;\n  /** Rotate one card forward on a timer. @default true */\n  autoPlay?: boolean;\n  /** Time (ms) a card is held at the front before the next step. @default 1000 */\n  holdDuration?: number;\n  /** Duration (ms) of one step. @default 700 */\n  stepDuration?: number;\n  /** Hold the autoplay while a pointer rests on a card. @default true */\n  pauseOnHover?: boolean;\n  /** Spin the ring by dragging it. @default true */\n  draggable?: boolean;\n  /** Gap between neighbouring cards at the widest point of the ring, in card\n   *  widths. `1` is just touching, above that they sit slightly apart, below\n   *  that they overlap. The ring repeats `items` until it holds this spacing,\n   *  so a wider ring means more cards rather than bigger gaps. @default 1.2 */\n  spread?: number;\n  /** Ceiling on the number of cards drawn around the ring. @default 64 */\n  maxCards?: number;\n  /** Multiplier on the drag rotation. @default 1 */\n  dragSensitivity?: number;\n  /** Node parked in the middle of the ring, behind the cards. */\n  centerLabel?: React.ReactNode;\n  /** @default true */\n  showCenterLabel?: boolean;\n}\n\nconst TAU = Math.PI * 2;\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.max(lo, Math.min(hi, v));\n\nexport function HaloReel({\n  items,\n  cardWidth = 130,\n  cardHeight = 180,\n  minScale = 0.4,\n  radiusXRatio = 0.45,\n  centerXRatio = 0,\n  radiusYRatio = 0.36,\n  autoPlay = true,\n  holdDuration = 1000,\n  stepDuration = 700,\n  pauseOnHover = true,\n  draggable = true,\n  spread = 1.2,\n  maxCards = 64,\n  dragSensitivity = 1,\n  centerLabel,\n  showCenterLabel = true,\n  className,\n  style,\n  ...props\n}: HaloReelProps) {\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const reduceMotion = useReducedMotion();\n\n  const count = items.length;\n\n  const rotation = useMotionValue(0);\n  const draggingRef = React.useRef(false);\n  const hoverRef = React.useRef(false);\n\n  const [size, setSize] = React.useState({ w: 0, h: 0 });\n  React.useEffect(() => {\n    const node = stageRef.current;\n    if (!node) return;\n    const measure = () =>\n      setSize({ w: node.offsetWidth, h: node.offsetHeight });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const radiusX = size.w * radiusXRatio;\n  const radiusY = size.h * radiusYRatio;\n\n  // The ring is sized by the stage and *filled* by repeating the items — the\n  // one way a wide ring and close cards can both be true. Neighbours sit\n  // `radius × step` apart at the widest point of each axis, so the tighter\n  // axis decides how many slots the ring needs; below that number a wider ring\n  // just means bigger gaps.\n  const slots = clamp(\n    Math.ceil(\n      TAU *\n        Math.max(\n          radiusX / (cardWidth * spread),\n          radiusY / (cardHeight * spread),\n        ),\n    ),\n    count,\n    Math.max(count, maxCards),\n  );\n  const step = slots ? TAU / slots : 0;\n\n  // Cards shrink continuously to fit whatever box they are given, instead of\n  // stepping at a breakpoint — a ring that jumps at 768px reads as broken on\n  // every width either side of it.\n  const fit = size.w\n    ? clamp(\n        Math.min(\n          size.w / (radiusX + cardWidth),\n          size.h / (2 * radiusY + cardHeight),\n        ),\n        0.45,\n        1,\n      )\n    : 1;\n  const cardW = cardWidth * fit;\n  const cardH = cardHeight * fit;\n\n  // Autoplay. Each step schedules the next, so a paused tick costs a re-check\n  // and nothing else — no interval keeps firing behind a held pointer.\n  React.useEffect(() => {\n    if (!autoPlay || reduceMotion || !count) return;\n\n    let timer = 0;\n    let controls: ReturnType<typeof animate> | undefined;\n\n    const tick = () => {\n      timer = window.setTimeout(() => {\n        if (draggingRef.current || (pauseOnHover && hoverRef.current)) {\n          tick();\n          return;\n        }\n        controls = animate(rotation, rotation.get() - step, {\n          duration: stepDuration / 1000,\n          ease: [0.4, 0, 0.2, 1],\n          onComplete: tick,\n        });\n      }, holdDuration);\n    };\n\n    tick();\n    return () => {\n      window.clearTimeout(timer);\n      controls?.stop();\n    };\n  }, [\n    autoPlay,\n    count,\n    holdDuration,\n    pauseOnHover,\n    reduceMotion,\n    rotation,\n    step,\n    stepDuration,\n  ]);\n\n  /* ── drag ──────────────────────────────────────────────────── */\n\n  const dragRef = React.useRef({ left: 0, top: 0, angle: 0 });\n\n  const pointerAngle = (e: React.PointerEvent) => {\n    const { left, top } = dragRef.current;\n    // Normalising by the radii un-squashes the ellipse, so a drag along its\n    // flat side turns the ring by the same amount as one along its tall side.\n    return Math.atan2(\n      (e.clientY - top - size.h / 2) / (radiusY || 1),\n      (e.clientX - left - size.w * centerXRatio) / (radiusX || 1),\n    );\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggable || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const rect = e.currentTarget.getBoundingClientRect();\n    dragRef.current = { left: rect.left, top: rect.top, angle: 0 };\n    dragRef.current.angle = pointerAngle(e);\n    draggingRef.current = true;\n    e.currentTarget.setPointerCapture(e.pointerId);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    const angle = pointerAngle(e);\n    // Wrap into (−π, π] so crossing the seam behind the ring is one small\n    // delta and not a full turn in the wrong direction.\n    const delta =\n      ((angle - dragRef.current.angle + Math.PI * 3) % TAU) - Math.PI;\n    dragRef.current.angle = angle;\n    rotation.set(rotation.get() + delta * dragSensitivity);\n  };\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n      e.currentTarget.releasePointerCapture(e.pointerId);\n    }\n    // Settle onto the nearest card — the ring never rests between two.\n    const snapped = Math.round(rotation.get() / step) * step;\n    if (reduceMotion) {\n      rotation.set(snapped);\n      return;\n    }\n    animate(rotation, snapped, { duration: 0.5, ease: [0.16, 1, 0.3, 1] });\n  };\n\n  const spinBy = (direction: number) => {\n    const target = Math.round(rotation.get() / step) * step - direction * step;\n    if (reduceMotion) {\n      rotation.set(target);\n      return;\n    }\n    animate(rotation, target, {\n      duration: stepDuration / 1000,\n      ease: [0.4, 0, 0.2, 1],\n    });\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const direction = { ArrowRight: 1, ArrowLeft: -1 }[e.key];\n    if (!direction) return;\n    e.preventDefault();\n    spinBy(direction);\n  };\n\n  if (!count) return null;\n\n  return (\n    <div\n      ref={stageRef}\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={props[\"aria-label\"] ?? \"Image carousel\"}\n      tabIndex={0}\n      onKeyDown={onKeyDown}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      className={cn(\n        \"relative h-[100dvh] w-full touch-pan-y select-none overflow-hidden outline-none\",\n        draggable && \"cursor-grab active:cursor-grabbing\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n        className,\n      )}\n      style={style}\n      {...props}\n    >\n      {showCenterLabel && centerLabel ? (\n        <div\n          className=\"pointer-events-none absolute inset-y-0 z-0 flex items-center justify-center px-4 text-center\"\n          // Parked in whatever space the ring leaves rather than at a fixed\n          // spot, so it can never end up underneath the cards at any width.\n          style={{\n            left: size.w * centerXRatio + radiusX + cardW / 2,\n            right: 0,\n          }}\n        >\n          {centerLabel}\n        </div>\n      ) : null}\n\n      {Array.from({ length: slots }, (_, i) => (\n        <WheelCard\n          key={i}\n          item={items[i % count]}\n          // The ring repeats `items` to stay dense. A screen reader should hear\n          // each image once, not once per lap, so only the first pass is real\n          // content and the copies are decoration.\n          decorative={i >= count}\n          index={i}\n          step={step}\n          rotation={rotation}\n          radiusX={radiusX}\n          radiusY={radiusY}\n          centerXRatio={centerXRatio}\n          minScale={minScale}\n          width={cardW}\n          height={cardH}\n          onHoverChange={(hovered) => {\n            hoverRef.current = hovered;\n          }}\n        />\n      ))}\n    </div>\n  );\n}\n\n/* ── card ────────────────────────────────────────────────────── */\n\nfunction WheelCard({\n  item,\n  index,\n  step,\n  rotation,\n  radiusX,\n  radiusY,\n  centerXRatio,\n  minScale,\n  width,\n  height,\n  decorative,\n  onHoverChange,\n}: {\n  item: HaloReelItem;\n  index: number;\n  step: number;\n  rotation: MotionValue<number>;\n  radiusX: number;\n  radiusY: number;\n  centerXRatio: number;\n  minScale: number;\n  width: number;\n  height: number;\n  decorative: boolean;\n  onHoverChange: (hovered: boolean) => void;\n}) {\n  const cos = useTransform(rotation, (r) => Math.cos(index * step + r));\n  const sin = useTransform(rotation, (r) => Math.sin(index * step + r));\n\n  const x = useTransform(cos, (c) => c * radiusX);\n  const y = useTransform(sin, (s) => s * radiusY);\n  const scale = useTransform(\n    cos,\n    (c) => minScale + (1 - minScale) * ((c + 1) / 2),\n  );\n  const zIndex = useTransform(scale, (s) => Math.round(s * 1000));\n\n  return (\n    <motion.div\n      role={decorative ? undefined : \"group\"}\n      aria-roledescription={decorative ? undefined : \"slide\"}\n      aria-hidden={decorative || undefined}\n      onPointerEnter={() => onHoverChange(true)}\n      onPointerLeave={() => onHoverChange(false)}\n      style={{\n        x,\n        y,\n        scale,\n        zIndex,\n        width,\n        height,\n        left: `${centerXRatio * 100}%`,\n        top: \"50%\",\n        marginLeft: -width / 2,\n        marginTop: -height / 2,\n      }}\n      className=\"absolute overflow-hidden shadow-xl\"\n    >\n      {item.src ? (\n        <img\n          src={item.src}\n          alt={decorative ? \"\" : (item.alt ?? \"\")}\n          draggable={false}\n          className=\"pointer-events-none absolute inset-0 h-full w-full select-none object-cover\"\n        />\n      ) : (\n        <div\n          className=\"flex h-full w-full flex-col items-center justify-center gap-1 bg-card p-3 text-center text-card-foreground\"\n          style={{\n            backgroundColor: item.bgColor,\n            color: item.textColor,\n          }}\n        >\n          {item.title ? (\n            <span className=\"text-2xl font-black leading-none\">\n              {item.title}\n            </span>\n          ) : null}\n          {item.subtitle ? (\n            <span className=\"text-[0.6rem] uppercase tracking-[0.2em] opacity-70\">\n              {item.subtitle}\n            </span>\n          ) : null}\n        </div>\n      )}\n    </motion.div>\n  );\n}\n\nexport default HaloReel;\n",
      "type": "registry:ui",
      "target": "components/ruixen/halo-reel.tsx"
    }
  ]
}