{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cube-roll-item",
  "type": "registry:block",
  "title": "Cube Roll Item",
  "description": "A numbered section index where each row is a 3D drum that rolls a quarter turn onto a filled face on hover. The roll follows the pointer — in from the top rolls down, out through the bottom keeps rolling down — while the rules draw in on load.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/cube-roll-item.tsx",
      "type": "registry:component",
      "target": "components/tweenui/cube-roll-item.tsx",
      "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef, type FocusEvent, type MouseEvent } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** The roll follows the pointer — skip it on touch, where there is no hover. */\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Keyboard focus rolls the row; a mouse click that happens to focus it does not. */\nconst isFocusVisible = (el: HTMLElement) => {\n  try {\n    return el.matches(':focus-visible');\n  } catch {\n    return true;\n  }\n};\n\n/** Settles a touch past the face, like a drum clicking into its detent. */\nconst ROLL_EASE = 'back.out(1.6)';\n\n/**\n * Each row is a four-sided drum turning on its X axis. Front and back carry the\n * resting face, top and bottom the filled one — so every quarter turn lands on\n * the opposite state, and the drum can keep rolling the way the pointer moves.\n * `--depth` matches the row height, which makes the drum a true square prism.\n */\nconst FACE_TRANSFORM = {\n  front: '[transform:translateZ(calc(var(--depth)/2))]',\n  back: '[transform:rotateX(180deg)_translateZ(calc(var(--depth)/2))]',\n  top: '[transform:rotateX(90deg)_translateZ(calc(var(--depth)/2))]',\n  bottom: '[transform:rotateX(-90deg)_translateZ(calc(var(--depth)/2))]',\n} as const;\n\ntype Side = keyof typeof FACE_TRANSFORM;\n\nconst SIDES: Side[] = ['front', 'back', 'top', 'bottom'];\n\nexport interface CubeRollItemEntry {\n  /** Large title in the middle of the row. */\n  title: string;\n  /** Short line on the right. */\n  label?: string;\n  /** Link target for the row. */\n  href: string;\n}\n\nexport interface CubeRollItemProps extends ComponentPropsWithoutRef<'nav'> {\n  /** Rows in the list, numbered in order. Defaults to a four-row sample. */\n  items?: CubeRollItemEntry[];\n}\n\nconst DEFAULT_ITEMS: CubeRollItemEntry[] = [\n  { title: 'Components', label: 'Buttons, text & logos', href: '#components' },\n  { title: 'Blocks', label: 'Ready-made sections', href: '#blocks' },\n  { title: 'Installation', label: 'One command, no config', href: '#installation' },\n  { title: 'Changelog', label: 'What shipped lately', href: '#changelog' },\n];\n\nfunction Face({ side, item, number }: { side: Side; item: CubeRollItemEntry; number: string }) {\n  const filled = side === 'top' || side === 'bottom';\n\n  return (\n    <div\n      data-roll-face={side}\n      aria-hidden={side === 'front' ? undefined : true}\n      className={cn(\n        'absolute inset-0 backface-hidden',\n        FACE_TRANSFORM[side],\n        filled\n          ? 'bg-[#045f64] text-white dark:bg-[#c6f56f] dark:text-[#071d1b]'\n          : 'bg-white text-[#12161F] dark:bg-[#0d1117] dark:text-white'\n      )}\n    >\n      {/* Equal side columns keep the title on the row's true center line. */}\n      <div className=\"flex h-full flex-wrap items-start px-3 pt-3.5 md:grid md:grid-cols-[1fr_2fr_1fr] md:items-center md:gap-6 md:px-4 md:pt-0\">\n        <span\n          className={cn(\n            'order-1 flex items-center gap-1.5 text-xs tabular-nums',\n            filled ? 'text-[#c6f56f] dark:text-[#045f64]' : 'text-[#045f64] dark:text-[#9fd4d6]'\n          )}\n        >\n          {number}\n          {filled && (\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M1 6h9.5M6.5 2 10.5 6l-4 4\" stroke=\"currentColor\" strokeWidth=\"1.25\" />\n            </svg>\n          )}\n        </span>\n        {/* Leading above 1 keeps descenders (g, y, p) inside the overflow box. */}\n        <span className=\"order-3 mt-2.5 w-full min-w-0 overflow-hidden text-center text-3xl leading-[1.3] font-normal tracking-tight text-ellipsis whitespace-nowrap md:order-2 md:mt-0 md:w-auto md:text-4xl\">\n          {item.title}\n        </span>\n        {item.label && (\n          <span\n            className={cn(\n              'order-2 ml-auto text-right text-[13px] tracking-wide md:order-3 md:ml-0',\n              filled\n                ? 'text-white/80 dark:text-[#071d1b]/75'\n                : 'text-[#045f64]/75 dark:text-[#9fd4d6]/80'\n            )}\n          >\n            {item.label}\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default function CubeRollItem({\n  items = DEFAULT_ITEMS,\n  className,\n  'aria-label': ariaLabel = 'Sections',\n  ...props\n}: CubeRollItemProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  /** Accumulated drum angle per row, in quarter turns of ±90°. */\n  const anglesRef = useRef<number[]>([]);\n  const activeRef = useRef<boolean[]>([]);\n\n  const { contextSafe } = useGSAP(\n    (_context, contextSafe) => {\n      anglesRef.current = items.map(() => 0);\n      activeRef.current = items.map(() => false);\n\n      if (prefersReducedMotion()) return;\n\n      // Park every drum half a turn over with its back face blanked, so each row\n      // starts as an empty slab and nothing shows in the fallback font.\n      const backContent = '[data-roll-face=\"back\"] > *';\n      gsap.set('[data-roll-rule]', { scaleX: 0 });\n      gsap.set('[data-roll-drum]', { rotationX: 180 });\n      gsap.set(backContent, { opacity: 0 });\n\n      let cancelled = false;\n\n      // Rules draw in while the drums flip over one after another, flashing\n      // their filled face on the way past.\n      const play = contextSafe!(() => {\n        if (cancelled) return;\n        gsap\n          .timeline({ onComplete: () => gsap.set(backContent, { opacity: 1 }) })\n          .to(\n            '[data-roll-rule]',\n            { scaleX: 1, duration: 0.9, ease: 'expo.inOut', stagger: 0.15 },\n            0\n          )\n          .to(\n            '[data-roll-drum]',\n            { rotationX: 0, duration: 0.9, ease: 'power4.inOut', stagger: 0.15 },\n            0.15\n          );\n      });\n\n      // Wait for web fonts so the text never swaps mid-reveal; give up after a\n      // beat so a slow or failed font can't hold the block hidden.\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      Promise.race([fonts, new Promise((resolve) => setTimeout(resolve, 1500))]).then(play);\n\n      return () => {\n        cancelled = true;\n      };\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  /** Turn a row a quarter — downward when `down`, upward otherwise. */\n  const turn = contextSafe((link: HTMLElement, index: number, active: boolean, down: boolean) => {\n    if (activeRef.current[index] === active) return;\n    activeRef.current[index] = active;\n    link.closest('li')?.setAttribute('data-active', String(active));\n\n    const drum = link.querySelector('[data-roll-drum]');\n    if (!drum) return;\n    // Hovered before the intro finished: the back face may still be blank.\n    gsap.set(link.querySelectorAll('[data-roll-face=\"back\"] > *'), { opacity: 1 });\n\n    if (prefersReducedMotion()) {\n      anglesRef.current[index] = active ? -90 : 0;\n      gsap.set(drum, { rotationX: anglesRef.current[index], opacity: 1 });\n      return;\n    }\n\n    anglesRef.current[index] = (anglesRef.current[index] ?? 0) + (down ? -90 : 90);\n    // `auto` takes rotationX from this drum's intro flip only, so hovering\n    // mid-reveal leaves the other rows' flips running.\n    gsap.to(drum, {\n      rotationX: anglesRef.current[index],\n      duration: active ? 0.6 : 0.7,\n      ease: ROLL_EASE,\n      overwrite: 'auto',\n    });\n  });\n\n  /** Did the pointer cross the top half of the row? */\n  const fromTop = (event: MouseEvent<HTMLElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    return event.clientY < rect.top + rect.height / 2;\n  };\n\n  return (\n    <nav\n      ref={rootRef}\n      data-cube-roll-item\n      aria-label={ariaLabel}\n      className={cn('mx-auto w-full max-w-[1100px] px-4 py-14 md:py-20', className)}\n      {...props}\n    >\n      {/* No gap between rows, so the filled face covers the whole row edge to edge. */}\n      <ul className=\"flex flex-col\">\n        {items.map((item, index) => {\n          const number = String(index + 1).padStart(3, '0');\n\n          return (\n            <li\n              key={item.href + item.title}\n              data-roll-row\n              data-active=\"false\"\n              className=\"relative h-[5.75rem] overflow-y-clip [--depth:5.75rem] md:h-[4.5rem] md:[--depth:4.5rem]\"\n            >\n              <span\n                data-roll-rule\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-x-0 top-0 z-10 h-px origin-left bg-[#045f64] dark:bg-[#9fd4d6]/60\"\n              />\n              <a\n                href={item.href}\n                className=\"block size-full outline-none perspective-[1200px]\"\n                // Enter from the top and the drum rolls down; leave through the\n                // bottom and it keeps rolling down rather than reversing.\n                onMouseEnter={(event) =>\n                  canHover() && turn(event.currentTarget, index, true, fromTop(event))\n                }\n                onMouseLeave={(event) =>\n                  canHover() && turn(event.currentTarget, index, false, !fromTop(event))\n                }\n                onFocus={(event: FocusEvent<HTMLAnchorElement>) =>\n                  isFocusVisible(event.currentTarget) &&\n                  turn(event.currentTarget, index, true, true)\n                }\n                onBlur={(event: FocusEvent<HTMLAnchorElement>) =>\n                  turn(event.currentTarget, index, false, true)\n                }\n              >\n                <div className=\"relative size-full [transform:translateZ(calc(var(--depth)/-2))] transform-3d\">\n                  {/* Under reduced motion the drum only snaps, so skip the layer promotion. */}\n                  <div\n                    data-roll-drum\n                    className=\"absolute inset-0 will-change-transform transform-3d motion-reduce:will-change-auto\"\n                  >\n                    {SIDES.map((side) => (\n                      <Face key={side} side={side} item={item} number={number} />\n                    ))}\n                  </div>\n                </div>\n              </a>\n            </li>\n          );\n        })}\n      </ul>\n      {/* Closing rule under the last row. */}\n      <span\n        data-roll-rule\n        aria-hidden=\"true\"\n        className=\"block h-px origin-left bg-[#045f64] dark:bg-[#9fd4d6]/60\"\n      />\n    </nav>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/cube-roll-item"
}