{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-gallery",
  "title": "Ring Gallery",
  "description": "A small set of items on a CSS 3D ring you spin by drag or keyboard, with no WebGL. Reduced motion gets a plain list.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/ring-gallery.tsx",
      "content": "import type * as React from \"react\"\nimport { useId } from \"react\"\nimport { useReducedMotion, useMotionTemplate, useTransform, motion } from \"motion/react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { RING_SHAPE, RingItem, type RingGalleryEntry, type RingGalleryVariant } from \"@/registry/miseui/ui/ring-gallery-item\"\nimport { ringItemShare, ringRadius, ringStep, useRingState, type RingState } from \"@/registry/miseui/ui/ring-gallery-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype RingGalleryEffect = \"momentum\"\n\nconst STAGE_PADDING_PX = 72\nconst PERSPECTIVE_ITEMS = 8\n\ntype RootAttributes = Omit<React.ComponentProps<\"div\">, \"children\">\n\ninterface RingGalleryProps extends RootAttributes {\n  /** What the ring holds, read out when someone focuses it. */\n  label: string\n  items: readonly RingGalleryEntry[]\n  /** `ring` cards seen from above, `medallion` round faces that always look at you, `cylinder` panels curving around the viewer. */\n  variant?: RingGalleryVariant\n  /** `momentum` lets a flick spin past several items and settle where it would come to rest. */\n  effect?: RingGalleryEffect\n  index?: number\n  defaultIndex?: number\n  onIndexChange?: (index: number) => void\n  previousLabel?: string\n  nextLabel?: string\n}\n\nconst FOCUS_CLASS = \"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n\ninterface ListProps {\n  label: string\n  listId: string\n  items: readonly RingGalleryEntry[]\n  ring: RingState\n}\n\n/** Reduced motion gets a plain list that keeps the same selection model: nothing here needs the spin to be understood. */\nfunction RingFlatList({ label, listId, items, ring }: ListProps) {\n  return (\n    <div\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label={label}\n      aria-activedescendant={`${listId}-${ring.active}`}\n      data-slot=\"ring-gallery-stage\"\n      data-layout=\"flat\"\n      className={cn(\"grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 rounded-lg\", FOCUS_CLASS)}\n      onKeyDown={ring.onKeyDown}\n    >\n      {items.map((entry, position) => (\n        <div\n          key={entry.id}\n          id={`${listId}-${position}`}\n          role=\"option\"\n          aria-selected={position === ring.active}\n          data-slot=\"ring-gallery-item\"\n          onClick={() => ring.select(position)}\n          className=\"flex min-h-12 min-w-0 cursor-pointer items-center gap-2 rounded-lg border p-2 text-[13px] aria-selected:border-foreground/40 aria-selected:bg-muted aria-selected:font-medium\"\n        >\n          {entry.media ? <span aria-hidden=\"true\" className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden\">{entry.media}</span> : null}\n          <span className=\"min-w-0\">{entry.label}</span>\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction RingStage({ label, listId, items, ring, variant }: ListProps & { variant: RingGalleryVariant }) {\n  const shape = RING_SHAPE[variant]\n  const count = items.length\n  const rotation = useTransform(ring.turn, (value) => -shape.direction * value * ringStep(count))\n  const place = variant === \"cylinder\" ? \"translateZ(var(--ring-radius))\" : \"translateZ(calc(var(--ring-radius) * -1))\"\n  // Static parts stay out of the template: useMotionTemplate drops a plain 0, and rotateX(deg) invalidates the whole transform.\n  const fixed = `${place} rotateX(${shape.tilt}deg)`\n  const transform = useMotionTemplate`${fixed} rotateY(${rotation}deg)`\n  const stageStyle = {\n    \"--ring-item\": `min(${shape.maxItem}px, ${(ringItemShare(count) * shape.fill).toFixed(2)}cqw)`,\n    \"--ring-item-height\": `calc(var(--ring-item) * ${shape.ratio})`,\n    \"--ring-radius\": `calc(var(--ring-item) * ${ringRadius(count).toFixed(3)})`,\n    height: `calc(var(--ring-item-height) + ${STAGE_PADDING_PX}px)`,\n    perspective: `calc(var(--ring-item) * ${PERSPECTIVE_ITEMS})`,\n  } as React.CSSProperties\n  return (\n    <div\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label={label}\n      aria-activedescendant={`${listId}-${ring.active}`}\n      data-slot=\"ring-gallery-stage\"\n      data-layout={variant}\n      className={cn(\"relative min-w-0 cursor-grab touch-pan-y overflow-hidden select-none active:cursor-grabbing focus-visible:ring-inset\", FOCUS_CLASS)}\n      style={stageStyle}\n      onKeyDown={ring.onKeyDown}\n      {...ring.handlers}\n    >\n      <motion.div className=\"absolute top-1/2 left-1/2 size-0 transform-3d\" style={{ transform }}>\n        {items.map((entry, position) => (\n          <RingItem\n            key={entry.id}\n            entry={entry}\n            optionId={`${listId}-${position}`}\n            index={position}\n            count={count}\n            turn={ring.turn}\n            variant={variant}\n            selected={position === ring.active}\n            onPick={ring.select}\n          />\n        ))}\n      </motion.div>\n    </div>\n  )\n}\n\nfunction RingButton({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className={cn(\n        \"inline-grid size-10 shrink-0 place-items-center rounded-full border bg-background transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-muted\",\n        FOCUS_CLASS,\n      )}\n    >\n      {children}\n    </button>\n  )\n}\n\nfunction RingGallery({\n  label, items, variant = \"ring\", effect, index, defaultIndex = 0, onIndexChange,\n  previousLabel = \"Previous item\", nextLabel = \"Next item\", className, ...props\n}: RingGalleryProps) {\n  const listId = useId()\n  const reduceMotion = useReducedMotion() ?? false\n  const ring = useRingState({ count: items.length, index, defaultIndex, onIndexChange, momentum: effect === \"momentum\", reduceMotion })\n  const current = items[ring.active]\n  return (\n    <div\n      data-slot=\"ring-gallery\"\n      data-variant={variant}\n      data-effect={effect}\n      className={cn(\"@container grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-3\", className)}\n      {...props}\n    >\n      {reduceMotion ? (\n        <RingFlatList label={label} listId={listId} items={items} ring={ring} />\n      ) : (\n        <RingStage label={label} listId={listId} items={items} ring={ring} variant={variant} />\n      )}\n      <div className=\"flex items-center gap-2\">\n        <RingButton label={previousLabel} onClick={() => ring.select(ring.active - 1)}>\n          <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n        </RingButton>\n        <div className=\"grid min-w-0 flex-1 gap-1 text-center\" aria-live=\"polite\" aria-atomic=\"true\" data-slot=\"ring-gallery-caption\">\n          <p className=\"text-[15px] font-semibold tracking-tight\">{current?.label}</p>\n          {current?.detail ? <p className=\"text-[13px] text-muted-foreground\">{current.detail}</p> : null}\n        </div>\n        <RingButton label={nextLabel} onClick={() => ring.select(ring.active + 1)}>\n          <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n        </RingButton>\n      </div>\n    </div>\n  )\n}\n\nexport { RingGallery, type RingGalleryProps, type RingGalleryEntry, type RingGalleryVariant, type RingGalleryEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/ring-gallery-item.tsx",
      "content": "import type * as React from \"react\"\nimport { motion, useTransform, type MotionValue } from \"motion/react\"\nimport { ringDim, ringFacing, ringLabelOpacity, ringStep } from \"@/registry/miseui/ui/ring-gallery-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nexport type RingGalleryVariant = \"ring\" | \"medallion\" | \"cylinder\"\n\nexport interface RingGalleryEntry {\n  id: string\n  label: string\n  /** Shown under the ring while this item is at the front. */\n  detail?: React.ReactNode\n  /** An image or icon. Fill the box; the shape comes from the variant. */\n  media?: React.ReactNode\n}\n\nexport const RING_SHAPE: Record<RingGalleryVariant, { ratio: number; tilt: number; direction: 1 | -1; maxItem: number; fill: number }> = {\n  ring: { ratio: 1.25, tilt: -8, direction: 1, maxItem: 168, fill: 1 },\n  medallion: { ratio: 1, tilt: -14, direction: 1, maxItem: 136, fill: 1 },\n  // The viewer stands inside, so the far wall is what they see and the order has to run the other way to read left to right.\n  cylinder: { ratio: 1.4, tilt: 0, direction: -1, maxItem: 208, fill: 1.35 },\n}\n\ninterface RingItemProps {\n  entry: RingGalleryEntry\n  optionId: string\n  index: number\n  count: number\n  turn: MotionValue<number>\n  variant: RingGalleryVariant\n  selected: boolean\n  onPick: (index: number) => void\n}\n\nfunction ItemText({ label, className, opacity }: { label: string; className?: string; opacity?: MotionValue<number> }) {\n  return <motion.span className={cn(\"line-clamp-2 text-[13px] leading-tight font-medium\", className)} style={{ opacity }}>{label}</motion.span>\n}\n\nfunction RingFace({ entry, labelOpacity }: { entry: RingGalleryEntry; labelOpacity: MotionValue<number> }) {\n  return (\n    <span className=\"absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border bg-card p-2 text-center text-card-foreground backface-hidden\">\n      {entry.media ? <span className=\"flex min-h-0 items-center justify-center\">{entry.media}</span> : null}\n      <ItemText label={entry.label} opacity={labelOpacity} />\n    </span>\n  )\n}\n\nfunction MedallionFace({ entry, selected }: { entry: RingGalleryEntry; selected: boolean }) {\n  return (\n    <span\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center overflow-hidden rounded-full border bg-card text-card-foreground\",\n        selected && \"border-foreground/40\",\n      )}\n    >\n      {entry.media ?? <ItemText label={entry.label} className=\"px-2 text-center\" />}\n    </span>\n  )\n}\n\nfunction CylinderFace({ entry, labelOpacity }: { entry: RingGalleryEntry; labelOpacity: MotionValue<number> }) {\n  return (\n    <span className=\"absolute inset-0 flex flex-col justify-between gap-2 overflow-hidden rounded-lg border bg-muted p-3 text-foreground backface-hidden\">\n      <span className=\"flex min-h-0 flex-1 items-center justify-center\">{entry.media}</span>\n      <ItemText label={entry.label} opacity={labelOpacity} />\n    </span>\n  )\n}\n\nexport function RingItem({ entry, optionId, index, count, turn, variant, selected, onPick }: RingItemProps) {\n  const { direction } = RING_SHAPE[variant]\n  const angle = direction * index * ringStep(count)\n  const dim = useTransform(turn, (value) => ringDim(ringFacing(value, index, count)))\n  const labelOpacity = useTransform(turn, (value) => ringLabelOpacity(ringFacing(value, index, count)))\n  // A medallion cancels the ring's rotation so its face keeps looking at the viewer, wherever it is on the ring.\n  const counter = useTransform(turn, (value) => `rotateY(${-direction * (index - value) * ringStep(count)}deg)`)\n  const place = variant === \"cylinder\"\n    ? `rotateY(${angle}deg) translateZ(calc(var(--ring-radius) * -1))`\n    : `rotateY(${angle}deg) translateZ(var(--ring-radius))`\n  return (\n    <div\n      id={optionId}\n      role=\"option\"\n      aria-selected={selected}\n      aria-label={entry.label}\n      data-slot=\"ring-gallery-item\"\n      onClick={() => onPick(index)}\n      className=\"absolute cursor-pointer transform-3d\"\n      style={{\n        width: \"var(--ring-item)\",\n        height: \"var(--ring-item-height)\",\n        left: \"calc(var(--ring-item) / -2)\",\n        top: \"calc(var(--ring-item-height) / -2)\",\n        transform: place,\n      }}\n    >\n      <motion.span className=\"absolute inset-0 block transform-3d\" style={variant === \"medallion\" ? { transform: counter } : undefined}>\n        {variant === \"ring\" ? <RingFace entry={entry} labelOpacity={labelOpacity} /> : null}\n        {variant === \"medallion\" ? <MedallionFace entry={entry} selected={selected} /> : null}\n        {variant === \"cylinder\" ? <CylinderFace entry={entry} labelOpacity={labelOpacity} /> : null}\n        {/* A lid rather than opacity on the item: opacity below 1 would flatten this 3D subtree. */}\n        <motion.span\n          aria-hidden=\"true\"\n          data-slot=\"ring-gallery-dim\"\n          className={cn(\n            \"pointer-events-none absolute inset-0 bg-background backface-hidden\",\n            variant === \"medallion\" && \"rounded-full\",\n            variant === \"ring\" && \"rounded-xl\",\n            variant === \"cylinder\" && \"rounded-lg\",\n          )}\n          style={{ opacity: dim }}\n        />\n        {variant === \"ring\" ? (\n          <span data-slot=\"ring-gallery-back\" aria-hidden=\"true\" className=\"absolute inset-0 rounded-xl border bg-muted backface-hidden [transform:rotateY(180deg)]\" />\n        ) : null}\n      </motion.span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/ring-gallery-state.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { animate, useMotionValue, type AnimationPlaybackControls } from \"motion/react\"\nimport { projectMomentum, SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\n\nconst GAP_SHARE = 0.12\nconst MIN_COUNT_FOR_POLYGON = 3\nconst TWO_ITEM_RADIUS = 0.75\nconst DRAG_SLOP_PX = 6\nconst MAX_DIM = 0.72\nconst LABEL_HIDDEN_FACING = 0.8\nconst LABEL_SHOWN_FACING = 0.98\n\n/** Positive modulo: the ring has no ends, and JavaScript's % keeps the sign of a negative index. */\nexport function wrapIndex(index: number, count: number) {\n  if (count <= 0) return 0\n  return ((index % count) + count) % count\n}\n\n/** Degrees between neighbours. */\nexport function ringStep(count: number) {\n  return 360 / Math.max(count, 1)\n}\n\n/** Ring radius in item widths: neighbours sit one item and a small gap apart along the chord. */\nexport function ringRadius(count: number) {\n  if (count < MIN_COUNT_FOR_POLYGON) return TWO_ITEM_RADIUS\n  return (1 + GAP_SHARE) / 2 / Math.tan(Math.PI / count)\n}\n\n/** Item width as a share of the stage, so the ring fits a 263 px cell and a wide page with no measuring. */\nexport function ringItemShare(count: number) {\n  return 100 / (2 * ringRadius(count) + 1)\n}\n\n/** 1 at the front of the ring, -1 at the back. */\nexport function ringFacing(turn: number, index: number, count: number) {\n  return Math.cos(((index - turn) * ringStep(count) * Math.PI) / 180)\n}\n\nexport function ringDim(facing: number) {\n  return ((1 - facing) / 2) * MAX_DIM\n}\n\n/** Perspective squeezes the name on a turned card into something unreadable, and the caption already says it. */\nexport function ringLabelOpacity(facing: number) {\n  return Math.min(Math.max((facing - LABEL_HIDDEN_FACING) / (LABEL_SHOWN_FACING - LABEL_HIDDEN_FACING), 0), 1)\n}\n\n/** The closest whole turn that brings `index` to the front, in whichever direction is shorter. */\nexport function nearestTurn(turn: number, index: number, count: number) {\n  const whole = Math.round(turn)\n  const ahead = wrapIndex(index - whole, count)\n  return whole + (ahead > count / 2 ? ahead - count : ahead)\n}\n\n/** A full stage of drag turns half the ring, whatever its width. */\nexport function dragToTurns(dragPx: number, stageWidth: number, count: number) {\n  if (stageWidth <= 0) return 0\n  return (dragPx / stageWidth) * (count / 2)\n}\n\n/** Where a released ring lands. Without momentum a flick moves one item at most, so nothing skips past unseen. */\nexport function settleTurn(turn: number, velocity: number, momentum: boolean, count: number) {\n  const reach = momentum ? count : 1\n  const projected = Math.max(-reach, Math.min(reach, projectMomentum(velocity)))\n  return Math.round(turn + projected)\n}\n\ninterface RingOptions {\n  count: number\n  index?: number\n  defaultIndex: number\n  onIndexChange?: (index: number) => void\n  momentum: boolean\n  reduceMotion: boolean\n}\n\ninterface Gesture {\n  startX: number\n  startTurn: number\n  lastX: number\n  lastTime: number\n  velocity: number\n  dragging: boolean\n}\n\nexport function useRingState({ count, index, defaultIndex, onIndexChange, momentum, reduceMotion }: RingOptions) {\n  const [uncontrolled, setUncontrolled] = useState(defaultIndex)\n  const active = wrapIndex(index ?? uncontrolled, count)\n  const turn = useMotionValue(active)\n  const aimed = useRef(active)\n  const running = useRef<AnimationPlaybackControls | null>(null)\n  const gesture = useRef<Gesture | null>(null)\n\n  const settle = (target: number, velocity = 0) => {\n    aimed.current = target\n    running.current?.stop()\n    if (reduceMotion) turn.set(target)\n    else running.current = animate(turn, target, { ...SPRING_DEFAULT, velocity })\n  }\n\n  // The motion value is the source of truth for the angle; this only catches up when the index moved some other way.\n  useEffect(() => {\n    if (wrapIndex(aimed.current, count) === active) return\n    const target = nearestTurn(aimed.current, active, count)\n    aimed.current = target\n    running.current?.stop()\n    if (reduceMotion) turn.set(target)\n    else running.current = animate(turn, target, SPRING_DEFAULT)\n  }, [active, count, reduceMotion, turn])\n\n  useEffect(() => () => running.current?.stop(), [])\n\n  const select = (next: number) => {\n    const wrapped = wrapIndex(next, count)\n    setUncontrolled(wrapped)\n    if (wrapped !== active) onIndexChange?.(wrapped)\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    const moves: Record<string, number> = { ArrowRight: active + 1, ArrowLeft: active - 1, Home: 0, End: count - 1 }\n    const next = moves[event.key]\n    if (next === undefined) return\n    event.preventDefault()\n    select(next)\n  }\n\n  const finish = () => {\n    const current = gesture.current\n    gesture.current = null\n    if (!current?.dragging) return\n    const target = settleTurn(turn.get(), current.velocity, momentum, count)\n    settle(target, current.velocity)\n    select(target)\n  }\n\n  const handlers = {\n    onPointerDown: (event: React.PointerEvent<HTMLElement>) => {\n      if (event.button !== 0) return\n      gesture.current = { startX: event.clientX, startTurn: turn.get(), lastX: event.clientX, lastTime: event.timeStamp, velocity: 0, dragging: false }\n    },\n    onPointerMove: (event: React.PointerEvent<HTMLElement>) => {\n      const current = gesture.current\n      if (!current) return\n      const drag = event.clientX - current.startX\n      if (!current.dragging && Math.abs(drag) < DRAG_SLOP_PX) return\n      if (!current.dragging) {\n        // Captured only once it is a drag, so a plain click still lands on the item under the pointer.\n        current.dragging = true\n        running.current?.stop()\n        event.currentTarget.setPointerCapture?.(event.pointerId)\n      }\n      const width = event.currentTarget.getBoundingClientRect().width\n      const elapsed = event.timeStamp - current.lastTime\n      if (elapsed > 0) current.velocity = (-dragToTurns(event.clientX - current.lastX, width, count) / elapsed) * 1000\n      current.lastX = event.clientX\n      current.lastTime = event.timeStamp\n      turn.set(current.startTurn - dragToTurns(drag, width, count))\n    },\n    onPointerUp: finish,\n    onPointerCancel: finish,\n  }\n\n  return { active, turn, select, onKeyDown, handlers }\n}\n\nexport type RingState = ReturnType<typeof useRingState>\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}