{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dice-roller",
  "type": "registry:ui",
  "title": "Dice Roller",
  "description": "CSS 3D dice with real pips that hop and tumble through several turns, then settle on a random face and announce the total.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/dice-roller.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useAnimate } from \"motion/react\";\n\nexport interface DiceRollerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onRoll\"> {\n  /** Number of dice, 1–5. */\n  count?: number;\n  /** Edge length of each die in px. */\n  size?: number;\n  /** Length of a roll in ms. */\n  duration?: number;\n  /** Extra full turns each die tumbles through before settling. */\n  spins?: number;\n  /** Faces shown before the first roll (deterministic, so server and client render the same). */\n  initialValues?: number[];\n  /** Seed for a repeatable sequence of rolls (tests, demos). Omit for Math.random. */\n  seed?: number;\n  /** Called with the new values as soon as a roll starts. */\n  onRoll?: (values: number[]) => void;\n  /** Show the sum below the dice. */\n  showTotal?: boolean;\n  dieColor?: string;\n  pipColor?: string;\n  /** Colour of the single pip on the 1 face, and of focus rings. */\n  accent?: string;\n  /** Accessible name of the dice group. */\n  label?: string;\n}\n\n/* Face layout: which face sits where on the cube, and the cube rotation that brings it to the front. */\nconst FACE_PLACEMENT: Record<number, string> = {\n  1: \"rotateY(0deg)\",\n  6: \"rotateY(180deg)\",\n  2: \"rotateY(90deg)\",\n  5: \"rotateY(-90deg)\",\n  3: \"rotateX(90deg)\",\n  4: \"rotateX(-90deg)\",\n};\nconst FACE_FRONT: Record<number, { x: number; y: number }> = {\n  1: { x: 0, y: 0 },\n  6: { x: 0, y: 180 },\n  2: { x: 0, y: -90 },\n  5: { x: 0, y: 90 },\n  3: { x: -90, y: 0 },\n  4: { x: 90, y: 0 },\n};\n/** Pip positions on a 3×3 grid (0 = top left, 8 = bottom right). */\nconst PIPS: Record<number, number[]> = {\n  1: [4],\n  2: [2, 6],\n  3: [2, 4, 6],\n  4: [0, 2, 6, 8],\n  5: [0, 2, 4, 6, 8],\n  6: [0, 3, 6, 2, 5, 8],\n};\nconst DEFAULT_FACES = [6, 3, 5, 2, 4];\nconst MAX_DICE = 5;\n\n/** Small, fast seeded PRNG (mulberry32). */\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** Angle reached by turning from `current` to an angle equivalent to `target`, plus whole extra turns. */\nfunction spinTo(current: number, target: number, turns: number, direction: 1 | -1) {\n  const delta = (((target - current) % 360) + 360) % 360;\n  return direction === 1 ? current + delta + 360 * turns : current - ((360 - delta) % 360) - 360 * turns;\n}\n\nfunction describe(values: number[]) {\n  if (values.length === 1) return `Rolled ${values[0]}`;\n  const list = values.length === 2 ? `${values[0]} and ${values[1]}` : `${values.slice(0, -1).join(\", \")} and ${values[values.length - 1]}`;\n  return `Rolled ${list}, total ${values.reduce((a, b) => a + b, 0)}`;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction Face({ value, accent, pipColor }: { value: number; accent: string; pipColor: string }) {\n  return (\n    <div\n      className=\"absolute inset-0 grid grid-cols-3 grid-rows-3 rounded-[18%] bg-[var(--dr-die)] bg-[linear-gradient(145deg,rgb(255_255_255/0.28),rgb(255_255_255/0)_45%,rgb(0_0_0/0.07))] p-[15%] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.07),inset_0_-0.12em_0.3em_rgb(0_0_0/0.1),inset_0_0.1em_0.2em_rgb(255_255_255/0.5)] [backface-visibility:hidden]\"\n      style={{ transform: `${FACE_PLACEMENT[value]} translateZ(calc(var(--dr-size) / 2))` }}\n    >\n      {Array.from({ length: 9 }, (_, cell) =>\n        PIPS[value].includes(cell) ? (\n          <span\n            key={cell}\n            className={`place-self-center rounded-full shadow-[inset_0_0.06em_0.1em_rgb(0_0_0/0.45)] ${value === 1 ? \"size-[95%]\" : \"size-[68%]\"}`}\n            style={{ background: value === 1 ? accent : pipColor }}\n          />\n        ) : (\n          <span key={cell} />\n        ),\n      )}\n    </div>\n  );\n}\n\n/**\n * CSS 3D dice that tumble when rolled. Each roll picks its faces at random in the event handler (or\n * from a seeded generator), spins every die through several extra turns on two axes while it hops,\n * and settles exactly on the chosen face. A polite live region then announces \"Rolled 4 and 6,\n * total 10\". The Roll button, a click on the tray, or R while the component has focus all roll.\n */\nexport function DiceRoller({\n  count = 2,\n  size = 72,\n  duration = 1400,\n  spins = 2,\n  initialValues,\n  seed,\n  onRoll,\n  showTotal = true,\n  dieColor = \"#F7F5F0\",\n  pipColor = \"#17191C\",\n  accent = \"#FF7448\",\n  label = \"Dice\",\n  className = \"\",\n  style,\n  onKeyDown,\n  ...rest\n}: DiceRollerProps) {\n  const dice = Math.max(1, Math.min(MAX_DICE, Math.round(count)));\n  const reduced = usePrefersReducedMotion();\n  const start = Array.from({ length: MAX_DICE }, (_, i) => {\n    const v = initialValues?.[i];\n    return v && v >= 1 && v <= 6 ? Math.round(v) : DEFAULT_FACES[i];\n  });\n  // Faces the dice are heading to, faces on display (updated once they settle), and cube angles.\n  const [shown, setShown] = React.useState(start);\n  const [rotations, setRotations] = React.useState(() => start.map((v) => ({ ...FACE_FRONT[v] })));\n  const [rolling, setRolling] = React.useState(false);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const random = React.useRef<{ seed: number | undefined; next: () => number } | null>(null);\n  const rollId = React.useRef(0);\n\n  const roll = () => {\n    if (!random.current || random.current.seed !== seed) {\n      random.current = { seed, next: seed === undefined ? Math.random : mulberry32(seed) };\n    }\n    const next = random.current.next;\n    const values = shown.slice();\n    const angles = rotations.map((r) => ({ ...r }));\n    for (let i = 0; i < dice; i++) {\n      values[i] = 1 + Math.floor(next() * 6);\n      const target = FACE_FRONT[values[i]];\n      angles[i] = {\n        x: spinTo(angles[i].x, target.x, spins + (next() < 0.5 ? 0 : 1), next() < 0.5 ? 1 : -1),\n        y: spinTo(angles[i].y, target.y, spins, next() < 0.5 ? 1 : -1),\n      };\n    }\n    const id = ++rollId.current;\n    const result = values.slice(0, dice);\n    setRotations(angles);\n    onRoll?.(result);\n\n    const settle = () => {\n      if (id !== rollId.current) return;\n      setShown(values);\n      setRolling(false);\n      setAnnouncement(describe(result));\n    };\n\n    if (reduced) {\n      for (let i = 0; i < dice; i++) {\n        void animate(`[data-die=\"${i}\"] [data-cube]`, { rotateX: angles[i].x, rotateY: angles[i].y }, { duration: 0 });\n      }\n      settle();\n      return;\n    }\n\n    setRolling(true);\n    setAnnouncement(\"\");\n    const seconds = duration / 1000;\n    const runs: Promise<unknown>[] = [];\n    for (let i = 0; i < dice; i++) {\n      const delay = i * 0.06;\n      const hop = size * (0.85 + next() * 0.4);\n      const keyTimes = [0, 0.32, 0.62, 0.8, 1];\n      runs.push(\n        animate(\n          `[data-die=\"${i}\"] [data-cube]`,\n          { rotateX: angles[i].x, rotateY: angles[i].y },\n          { duration: seconds, delay, ease: [0.15, 0.7, 0.2, 1] },\n        ).then(() => undefined),\n      );\n      void animate(`[data-die=\"${i}\"] [data-hop]`, { y: [0, -hop, 0, -hop * 0.16, 0] }, { duration: seconds * 0.8, delay, times: keyTimes, ease: \"easeInOut\" });\n      void animate(\n        `[data-die=\"${i}\"] [data-shadow]`,\n        { scale: [1, 0.5, 1, 0.88, 1], opacity: [1, 0.35, 1, 0.85, 1] },\n        { duration: seconds * 0.8, delay, times: keyTimes, ease: \"easeInOut\" },\n      );\n    }\n    void Promise.all(runs).then(settle);\n  };\n\n  const values = shown.slice(0, dice);\n  const total = values.reduce((a, b) => a + b, 0);\n\n  return (\n    <div\n      {...rest}\n      className={`flex flex-col items-center gap-5 ${className}`}\n      style={{ ...style, [\"--dr-accent\" as string]: accent, [\"--dr-die\" as string]: dieColor, [\"--dr-size\" as string]: `${size}px` }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.repeat) return;\n        if (event.key !== \"r\" && event.key !== \"R\") return;\n        const target = event.target as HTMLElement;\n        if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n        event.preventDefault();\n        roll();\n      }}\n    >\n      <div\n        ref={scope}\n        role=\"group\"\n        aria-label={label}\n        aria-roledescription=\"dice tray\"\n        onClick={roll}\n        className=\"flex cursor-pointer flex-wrap items-end justify-center\"\n        style={{ gap: Math.round(size * 0.45), paddingTop: Math.round(size * 0.9) }}\n      >\n        {Array.from({ length: dice }, (_, i) => (\n          <div\n            key={i}\n            data-die={i}\n            role=\"img\"\n            aria-label={rolling ? `Die ${i + 1}, rolling` : `Die ${i + 1} showing ${values[i]}`}\n            className=\"relative flex flex-col items-center\"\n            style={{ width: size }}\n          >\n            <motion.div data-hop=\"\" className=\"relative [perspective:900px]\" style={{ width: size, height: size }}>\n              {/* A fixed viewing angle so three faces read as a solid cube. */}\n              <div className=\"size-full [transform-style:preserve-3d]\" style={{ transform: \"rotateX(-18deg) rotateY(-24deg)\" }}>\n                <motion.div\n                  data-cube=\"\"\n                  aria-hidden=\"true\"\n                  className=\"relative size-full [transform-style:preserve-3d]\"\n                  initial={{ rotateX: rotations[i].x, rotateY: rotations[i].y }}\n                >\n                  {/* Inner planes fill the gaps the rounded corners would otherwise leave open. */}\n                  <div className=\"absolute inset-[2%] bg-[color-mix(in_srgb,var(--dr-die)_82%,#000)]\" />\n                  <div className=\"absolute inset-[2%] bg-[color-mix(in_srgb,var(--dr-die)_82%,#000)] [transform:rotateX(90deg)]\" />\n                  <div className=\"absolute inset-[2%] bg-[color-mix(in_srgb,var(--dr-die)_82%,#000)] [transform:rotateY(90deg)]\" />\n                  {[1, 2, 3, 4, 5, 6].map((face) => (\n                    <Face key={face} value={face} accent={accent} pipColor={pipColor} />\n                  ))}\n                </motion.div>\n              </div>\n            </motion.div>\n            <motion.div\n              data-shadow=\"\"\n              aria-hidden=\"true\"\n              className=\"mt-[18%] h-[14%] w-[92%] rounded-[50%] bg-black/25 blur-[6px] dark:bg-black/80\"\n            />\n          </div>\n        ))}\n      </div>\n\n      <div className=\"flex items-center gap-4\">\n        <button\n          type=\"button\"\n          onClick={roll}\n          aria-keyshortcuts=\"R\"\n          className=\"inline-flex h-10 items-center gap-2.5 rounded-full bg-zinc-900 pl-4 pr-2 text-[13.5px] font-medium text-white outline-hidden transition-[transform,background-color] hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dr-accent)] active:scale-[0.97] dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200\"\n        >\n          {dice === 1 ? \"Roll die\" : \"Roll dice\"}\n          <kbd aria-hidden=\"true\" className=\"grid size-6 place-items-center rounded-full bg-white/15 font-mono text-[10px] dark:bg-zinc-950/10\">\n            R\n          </kbd>\n        </button>\n        {showTotal ? (\n          <div aria-hidden=\"true\" className=\"flex min-w-[7.5rem] flex-col\">\n            <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              {rolling ? \"Rolling\" : dice === 1 ? \"Result\" : values.join(\" + \")}\n            </span>\n            <span className=\"text-[26px] font-semibold leading-none tabular-nums tracking-[-0.03em] text-zinc-950 dark:text-white\">\n              {rolling ? \"··\" : total}\n            </span>\n          </div>\n        ) : null}\n      </div>\n\n      <p aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/dice-roller"
}
