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