{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "coverflow",
  "title": "Coverflow",
  "description": "Cards on a 3D shelf that you flick through. A throw carries momentum and lands on the nearest card.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/button.json",
    "https://spotcheckui.com/r/use-flick.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/coverflow.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { motion, type MotionValue, useTransform } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/registry/spotcheck/components/spotcheck/button\"\nimport { useFlick } from \"@/registry/spotcheck/hooks/use-flick\"\n\nconst MAX_CARD_WIDTH = 220\nconst CARD_FRACTION = 0.5\nconst OVERLAP = 0.62\nconst STACK = 0.3\nconst SIDE_DEPTH = 150\nconst STACK_DEPTH = 40\nconst SIDE_ANGLE = 55\nconst HIDDEN_AFTER = 3.6\nconst DEFAULT_WIDTH = 200\n\nexport type CoverflowItem = {\n  value: string\n  /** The card itself. Its text is what screen readers read. */\n  content: React.ReactNode\n  /** One line printed beside the buttons while this card is selected. */\n  summary?: string\n}\n\n/** Places a card from its distance to the centre, in cards. Past one card they stack up. */\nexport function cardPose(distance: number, width: number) {\n  const reach = Math.abs(distance)\n  const side = Math.min(reach, 1)\n  const beyond = Math.max(0, reach - 1)\n  return {\n    x: Math.sign(distance) * (side * width * OVERLAP + beyond * width * STACK),\n    z: -side * SIDE_DEPTH - beyond * STACK_DEPTH,\n    rotateY: -Math.max(-1, Math.min(1, distance)) * SIDE_ANGLE,\n    zIndex: 100 - Math.round(reach * 10),\n    opacity: reach > HIDDEN_AFTER ? 0 : 1,\n  }\n}\n\ntype CoverCardProps = {\n  id: string\n  index: number\n  width: number\n  count: number\n  item: CoverflowItem\n  selected: boolean\n  position: MotionValue<number>\n}\n\nfunction CoverCard({ id, index, width, count, item, selected, position }: CoverCardProps) {\n  const transform = useTransform(position, (value) => {\n    const { x, z, rotateY } = cardPose(index - value, width)\n    return `translate(-50%, -50%) translateX(${x}px) translateZ(${z}px) rotateY(${rotateY}deg)`\n  })\n  const zIndex = useTransform(position, (value) => cardPose(index - value, width).zIndex)\n  const opacity = useTransform(position, (value) => cardPose(index - value, width).opacity)\n\n  return (\n    <motion.div\n      id={id}\n      role=\"option\"\n      aria-selected={selected}\n      aria-posinset={index + 1}\n      aria-setsize={count}\n      data-index={index}\n      className=\"border-ink bg-raise absolute top-1/2 left-1/2 flex h-[262px] w-[min(220px,50%)] flex-col gap-1 overflow-hidden border-(length:--rule) p-3.5 shadow-[6px_6px_0_var(--ink)] select-none will-change-transform\"\n      style={{ transform, zIndex, opacity }}\n    >\n      {selected && <span aria-hidden className=\"bg-accent-ink absolute inset-x-0 top-0 h-[7px]\" />}\n      {item.content}\n    </motion.div>\n  )\n}\n\ntype CoverflowProps = {\n  /** Names the list for screen readers. */\n  label: string\n  items: CoverflowItem[]\n  /** The selected card's value, when you control it. */\n  value?: string\n  /** The starting value, when the shelf manages itself. */\n  defaultValue?: string\n  /** Runs when the shelf lands on a different card. */\n  onValueChange?: (value: string) => void\n  /** Added to the outer element. */\n  className?: string\n}\n\nexport function Coverflow({ label, items, value, defaultValue, onValueChange, className }: CoverflowProps) {\n  const [internal, setInternal] = React.useState(defaultValue ?? items[0]?.value)\n  const current = value ?? internal\n  const index = Math.max(\n    0,\n    items.findIndex((item) => item.value === current),\n  )\n  const count = items.length\n  const baseId = React.useId()\n  const stage = React.useRef<HTMLDivElement>(null)\n  const [width, setWidth] = React.useState(DEFAULT_WIDTH)\n\n  React.useLayoutEffect(() => {\n    const element = stage.current\n    if (!element) return\n    const measure = () => setWidth(Math.min(MAX_CARD_WIDTH, element.clientWidth * CARD_FRACTION))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [])\n\n  const { position, settle, bind } = useFlick({\n    count,\n    index,\n    onIndexChange: (next) => {\n      const item = items[next]\n      if (!item) return\n      setInternal(item.value)\n      onValueChange?.(item.value)\n    },\n    axis: \"x\",\n    step: () => width * OVERLAP,\n    onTap: (event) => {\n      // The stage holds the pointer, so the card has to be found from the press point.\n      const card = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>(\"[data-index]\")\n      if (card) settle(Number(card.dataset.index))\n    },\n  })\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const moves: Record<string, number> = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: count - 1 }\n    const next = moves[event.key]\n    if (next === undefined) return\n    event.preventDefault()\n    settle(next)\n  }\n\n  return (\n    <div className={cn(\"flex min-w-0 flex-col gap-3.5 font-(family-name:--f-body)\", className)}>\n      <div\n        ref={stage}\n        role=\"listbox\"\n        tabIndex={0}\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        aria-activedescendant={`${baseId}-${index}`}\n        className=\"spotcheck-focus relative h-80 cursor-grab touch-pan-y overflow-hidden border border-dashed border-[color-mix(in_srgb,var(--ink)_40%,transparent)] perspective-[1100px] active:cursor-grabbing\"\n        onKeyDown={onKeyDown}\n        {...bind}\n      >\n        {items.map((item, card) => (\n          <CoverCard\n            key={item.value}\n            id={`${baseId}-${card}`}\n            index={card}\n            width={width}\n            count={count}\n            item={item}\n            selected={card === index}\n            position={position}\n          />\n        ))}\n      </div>\n      <div className=\"flex flex-wrap items-center justify-between gap-2.5\">\n        <div className=\"flex gap-2\">\n          <Button aria-label=\"Previous\" disabled={index === 0} onClick={() => settle(index - 1)}>\n            <span aria-hidden>←</span>\n          </Button>\n          <Button aria-label=\"Next\" disabled={index === count - 1} onClick={() => settle(index + 1)}>\n            <span aria-hidden>→</span>\n          </Button>\n        </div>\n        {items[index]?.summary && (\n          <span className=\"spotcheck-label text-ink!\" data-testid=\"coverflow-summary\">\n            {items[index].summary}\n          </span>\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "depth"
  ],
  "type": "registry:component"
}