{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cube-tabs",
  "title": "Cube Tabs",
  "description": "Four tabs on the faces of a cube that turns the short way and settles.",
  "dependencies": [
    "animejs"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/cube-tabs.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { animate, type JSAnimation } from \"animejs\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/spotcheck/hooks/use-reduced-motion\"\n\nconst FACE_ANGLE = 90\nconst FACES = 4\nconst TURN_MS = 1100\nconst TURN_EASE = \"outElastic(1, .8)\"\nconst PULL_BACK = 0.22\nconst DEFAULT_WIDTH = 360\n\nexport type CubeFace = {\n  value: string\n  label: string\n  content: React.ReactNode\n}\n\n/** Quarter turns from one face to another, the short way. Going from 0 to 3 is -1, not 3. */\nexport function shortestTurn(from: number, to: number) {\n  const forward = ((to - from) % FACES + FACES) % FACES\n  return forward === FACES - 1 ? -1 : forward\n}\n\ntype CubeTabsProps = {\n  /** Names the tab list for screen readers. */\n  label: string\n  /** Exactly four faces. Each one holds your own content. */\n  faces: [CubeFace, CubeFace, CubeFace, CubeFace]\n  /** The selected face's value, when you control it. */\n  value?: string\n  /** The starting value, when the tabs manage themselves. */\n  defaultValue?: string\n  /** Runs when a different face is selected. */\n  onValueChange?: (value: string) => void\n  /** Added to the outer element. */\n  className?: string\n}\n\nexport function CubeTabs({ label, faces, value, defaultValue, onValueChange, className }: CubeTabsProps) {\n  const reduced = useReducedMotion()\n  const [internal, setInternal] = React.useState(defaultValue ?? faces[0].value)\n  const current = value ?? internal\n  const index = Math.max(\n    0,\n    faces.findIndex((face) => face.value === current),\n  )\n  const baseId = React.useId()\n\n  const box = React.useRef<HTMLDivElement>(null)\n  const cube = React.useRef<HTMLDivElement>(null)\n  const tabs = React.useRef<(HTMLButtonElement | null)[]>([])\n  const turn = React.useRef<JSAnimation | null>(null)\n  const shown = React.useRef({ rotation: 0, width: DEFAULT_WIDTH })\n  const landed = React.useRef(index)\n  const target = React.useRef(index * -FACE_ANGLE)\n  const [width, setWidth] = React.useState(DEFAULT_WIDTH)\n  const [bar, setBar] = React.useState({ left: 0, width: 0 })\n\n  const paint = React.useCallback((rotation: number, pull = 0) => {\n    shown.current.rotation = rotation\n    if (cube.current) {\n      cube.current.style.transform = `translateZ(${-shown.current.width / 2 - pull}px) rotateY(${rotation}deg)`\n    }\n  }, [])\n\n  React.useLayoutEffect(() => {\n    const element = box.current\n    if (!element) return\n    const measure = () => {\n      shown.current.width = element.clientWidth\n      setWidth(element.clientWidth)\n      paint(shown.current.rotation)\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [paint])\n\n  React.useLayoutEffect(() => {\n    const tab = tabs.current[index]\n    if (tab) setBar({ left: tab.offsetLeft, width: tab.offsetWidth })\n  }, [index, width])\n\n  // The last-landed face decides the direction, and rotation only ever accumulates from there.\n  React.useLayoutEffect(() => {\n    if (landed.current === index) return\n    const from = shown.current.rotation\n    const to = target.current - shortestTurn(landed.current, index) * FACE_ANGLE\n    target.current = to\n    landed.current = index\n    cube.current?.setAttribute(\"data-rotation\", String(to))\n\n    turn.current?.cancel()\n    if (reduced) return paint(to)\n\n    const state = { rotation: from }\n    turn.current = animate(state, {\n      rotation: to,\n      duration: TURN_MS,\n      ease: TURN_EASE,\n      onUpdate: () => {\n        const progress = Math.min(1, Math.abs((state.rotation - from) / (to - from || 1)))\n        paint(state.rotation, Math.sin(progress * Math.PI) * shown.current.width * PULL_BACK)\n      },\n      onComplete: () => paint(to),\n    })\n  }, [index, paint, reduced])\n\n  React.useEffect(() => () => void turn.current?.cancel(), [])\n\n  const select = (next: number) => {\n    const face = faces[next]\n    if (!face || next === index) return\n    setInternal(face.value)\n    onValueChange?.(face.value)\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const moves: Record<string, number> = {\n      ArrowRight: (index + 1) % FACES,\n      ArrowLeft: (index + FACES - 1) % FACES,\n      Home: 0,\n      End: FACES - 1,\n    }\n    const next = moves[event.key]\n    if (next === undefined) return\n    event.preventDefault()\n    select(next)\n    tabs.current[next]?.focus()\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        role=\"tablist\"\n        aria-label={label}\n        className=\"border-ink relative flex [scrollbar-width:none] overflow-x-auto border-b-(length:--rule)\"\n        onKeyDown={onKeyDown}\n      >\n        {faces.map((face, tab) => (\n          <button\n            key={face.value}\n            ref={(element) => {\n              tabs.current[tab] = element\n            }}\n            type=\"button\"\n            role=\"tab\"\n            id={`${baseId}-tab-${tab}`}\n            aria-selected={tab === index}\n            aria-controls={`${baseId}-panel-${tab}`}\n            tabIndex={tab === index ? 0 : -1}\n            className=\"spotcheck-heading spotcheck-focus text-mute aria-selected:text-ink cursor-pointer px-2.5 pt-2 pb-2.5 text-base whitespace-nowrap -outline-offset-2 md:px-3.5 md:text-lg\"\n            onClick={() => select(tab)}\n          >\n            {face.label}\n          </button>\n        ))}\n        <span\n          aria-hidden\n          className=\"bg-accent-ink absolute bottom-0 h-[5px] transition-[left,width] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none\"\n          style={bar}\n        />\n      </div>\n      <div ref={box} className=\"relative h-62.5 overflow-hidden perspective-[1300px]\">\n        <div\n          ref={cube}\n          data-testid=\"cube\"\n          data-rotation={0}\n          className=\"absolute inset-0 transform-3d\"\n          style={{ transform: `translateZ(${-width / 2}px) rotateY(0deg)` }}\n        >\n          {faces.map((face, panel) => (\n            <div\n              key={face.value}\n              role=\"tabpanel\"\n              id={`${baseId}-panel-${panel}`}\n              aria-labelledby={`${baseId}-tab-${panel}`}\n              aria-hidden={panel !== index}\n              inert={panel !== index}\n              className=\"border-ink bg-raise absolute inset-0 flex flex-col gap-0.5 border-(length:--rule) px-4 py-3.5 backface-hidden\"\n              style={{ transform: `rotateY(${panel * FACE_ANGLE}deg) translateZ(${width / 2}px)` }}\n            >\n              {face.content}\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "depth"
  ],
  "type": "registry:component"
}