Dice
import { useState, type CSSProperties } from 'react';
const S = 60; // the die's edge
// Faces are placed so opposite ones add to seven: 1 front, 6 back, 2 top, 5 bottom, 3 right, 4 left.
const FACES = [
{ n: 1, place: `translateZ(${S / 2}px)`, show: [0, 0] },
{ n: 6, place: `rotateY(180deg) translateZ(${S / 2}px)`, show: [0, 180] },
{ n: 2, place: `rotateX(90deg) translateZ(${S / 2}px)`, show: [-90, 0] },
{ n: 5, place: `rotateX(-90deg) translateZ(${S / 2}px)`, show: [90, 0] },
{ n: 3, place: `rotateY(90deg) translateZ(${S / 2}px)`, show: [0, -90] },
{ n: 4, place: `rotateY(-90deg) translateZ(${S / 2}px)`, show: [0, 90] },
];
const PIPS: Record<number, number[]> = { 1: [4], 2: [0, 8], 3: [0, 4, 8], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8] };
// The die rolls to a face and always forward: every roll adds a whole turn on one axis or the other.
function pose(n: number, roll: number) {
const [x, y] = FACES.find((f) => f.n === n)!.show;
return `rotateX(${x - 360 * Math.ceil(roll / 2)}deg) rotateY(${y + 360 * Math.floor(roll / 2)}deg)`;
}
const css = `
.slider-dice { --bone: light-dark(#fbfcfd, #e5e6e9); --pip: light-dark(#373a40, #121417); display: grid; gap: 14px; width: 230px; }
.slider-dice__table { position: relative; height: 118px; perspective: 520px; }
.slider-dice__shadow { position: absolute; left: 50%; top: 96px; width: 74px; height: 14px; translate: -50% 0; border-radius: 50%; box-shadow: inset 0 0 0 40px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); filter: blur(4px); }
.slider-dice__hop { position: absolute; left: 50%; top: 22px; width: ${S}px; height: ${S}px; margin-left: -${S / 2}px; transform-style: preserve-3d; }
.slider-dice[data-roll='a'] .slider-dice__hop { animation: slider-dice-hop-a 0.7s ease-out; }
.slider-dice[data-roll='b'] .slider-dice__hop { animation: slider-dice-hop-b 0.7s ease-out; }
.slider-dice[data-roll='a'] .slider-dice__shadow { animation: slider-dice-shadow-a 0.7s ease-out; }
.slider-dice[data-roll='b'] .slider-dice__shadow { animation: slider-dice-shadow-b 0.7s ease-out; }
/* Seen from a little above and to the side, so three faces show. */
.slider-dice__view { position: absolute; inset: 0; transform-style: preserve-3d; transform: rotateX(-24deg) rotateY(-32deg); }
.slider-dice__cube { position: absolute; inset: 0; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.25, 0.9, 0.3, 1.06); }
.slider-dice__face { position: absolute; inset: 0; display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr); padding: 9px; box-sizing: border-box; border-radius: 11px; background: radial-gradient(circle at 30% 25%, var(--bone), color-mix(in oklab, var(--bone) 88%, light-dark(#6d7179, #7d8087))); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--bone) 80%, light-dark(#6d7179, #7d8087)), inset 0 -3px 6px color-mix(in oklab, light-dark(#6d7179, #7d8087) 30%, transparent); backface-visibility: hidden; }
/* A core just inside the faces fills the rounded corners. */
.slider-dice__core { position: absolute; inset: 3px; background: color-mix(in oklab, var(--bone) 85%, light-dark(#6d7179, #7d8087)); }
.slider-dice__pip { place-self: center; width: 10px; height: 10px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, color-mix(in oklab, var(--pip) 70%, var(--bone)), var(--pip) 70%); }
.slider-dice__track { position: relative; height: 40px; }
.slider-dice__rail { position: absolute; left: 13px; right: 13px; top: 11px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-dice__stop { position: absolute; top: 24px; translate: -50% 0; color: light-dark(#6d7179, #7d8087); font-size: 10px; font-weight: 600; transition: color 0.2s; }
.slider-dice__stop[data-on] { color: light-dark(#373a40, #f1f2f3); }
.slider-dice__thumb { position: absolute; top: 0; left: calc(var(--slider-dice-v) * (100% - 26px)); width: 26px; height: 26px; border-radius: 7px; background: var(--bone); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px color-mix(in oklab, var(--bone) 75%, light-dark(#6d7179, #7d8087)); transition: left 0.25s cubic-bezier(0.3, 1.3, 0.5, 1), rotate 0.25s; rotate: calc(var(--slider-dice-v) * 450deg); }
.slider-dice__thumb::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; translate: -50% -50%; border-radius: 50%; background: var(--pip); }
.slider-dice:has(.slider-dice__input:focus-visible) .slider-dice__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-dice__input { position: absolute; left: 0; top: 0; width: 100%; height: 26px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-dice__input::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; }
.slider-dice__input::-moz-range-thumb { width: 26px; height: 26px; border: 0; }
@keyframes slider-dice-hop-a { 0% { transform: translateY(0); } 35% { transform: translateY(-30px); } 70% { transform: translateY(0); } 82% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
@keyframes slider-dice-hop-b { 0% { transform: translateY(0); } 35% { transform: translateY(-30px); } 70% { transform: translateY(0); } 82% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
@keyframes slider-dice-shadow-a { 35% { scale: 0.6; opacity: 0.5; } 70%, 100% { scale: 1; opacity: 1; } }
@keyframes slider-dice-shadow-b { 35% { scale: 0.6; opacity: 0.5; } 70%, 100% { scale: 1; opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.slider-dice * { animation: none !important; transition: none !important; }
}
`;
export function Preview() {
const [state, setState] = useState({ n: 4, roll: 0 });
const { n, roll } = state;
return (
<>
<style href="kit-sliders-dice" precedence="kit">
{css}
</style>
<div className="slider-dice" data-roll={roll === 0 ? undefined : roll % 2 ? 'a' : 'b'} style={{ '--slider-dice-v': (n - 1) / 5 } as CSSProperties}>
<div className="slider-dice__table" aria-hidden="true">
<span className="slider-dice__shadow" />
<span className="slider-dice__hop">
<span className="slider-dice__view">
<span className="slider-dice__cube" style={{ transform: pose(n, roll) }}>
<span className="slider-dice__core" style={{ transform: 'rotateY(90deg)' }} />
<span className="slider-dice__core" style={{ transform: 'rotateX(90deg)' }} />
<span className="slider-dice__core" />
{FACES.map((f) => (
<span key={f.n} className="slider-dice__face" style={{ transform: f.place }}>
{Array.from({ length: 9 }, (_, i) => (PIPS[f.n].includes(i) ? <i key={i} className="slider-dice__pip" style={{ gridArea: `${Math.floor(i / 3) + 1} / ${(i % 3) + 1}` }} /> : null))}
</span>
))}
</span>
</span>
</span>
</div>
<div className="slider-dice__track">
<span className="slider-dice__rail" aria-hidden="true" />
{[1, 2, 3, 4, 5, 6].map((s) => (
<span key={s} className="slider-dice__stop" data-on={s === n ? '' : undefined} style={{ left: `calc(13px + ${(s - 1) / 5} * (100% - 26px))` }} aria-hidden="true">
{s}
</span>
))}
<span className="slider-dice__thumb" aria-hidden="true" />
<input className="slider-dice__input" type="range" min={1} max={6} value={n} onChange={(e) => setState((s) => ({ n: +e.target.value, roll: s.roll + 1 }))} aria-label="Number" aria-valuetext={`${n}`} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDice(el) on the mounted element instead.
<div class="slider-dice" style="--slider-dice-v:0.6">
<div class="slider-dice__table" aria-hidden="true">
<span class="slider-dice__shadow"></span>
<span class="slider-dice__hop">
<span class="slider-dice__view">
<span class="slider-dice__cube" style="transform:rotateX(0deg) rotateY(90deg)">
<span class="slider-dice__core" style="transform:rotateY(90deg)"></span>
<span class="slider-dice__core" style="transform:rotateX(90deg)"></span>
<span class="slider-dice__core"></span>
<span class="slider-dice__face" style="transform:translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:2 / 2"></i>
</span>
<span class="slider-dice__face" style="transform:rotateY(180deg) translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:1 / 1"></i>
<i class="slider-dice__pip" style="grid-area:1 / 3"></i>
<i class="slider-dice__pip" style="grid-area:2 / 1"></i>
<i class="slider-dice__pip" style="grid-area:2 / 3"></i>
<i class="slider-dice__pip" style="grid-area:3 / 1"></i>
<i class="slider-dice__pip" style="grid-area:3 / 3"></i>
</span>
<span class="slider-dice__face" style="transform:rotateX(90deg) translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:1 / 1"></i>
<i class="slider-dice__pip" style="grid-area:3 / 3"></i>
</span>
<span class="slider-dice__face" style="transform:rotateX(-90deg) translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:1 / 1"></i>
<i class="slider-dice__pip" style="grid-area:1 / 3"></i>
<i class="slider-dice__pip" style="grid-area:2 / 2"></i>
<i class="slider-dice__pip" style="grid-area:3 / 1"></i>
<i class="slider-dice__pip" style="grid-area:3 / 3"></i>
</span>
<span class="slider-dice__face" style="transform:rotateY(90deg) translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:1 / 1"></i>
<i class="slider-dice__pip" style="grid-area:2 / 2"></i>
<i class="slider-dice__pip" style="grid-area:3 / 3"></i>
</span>
<span class="slider-dice__face" style="transform:rotateY(-90deg) translateZ(30px)">
<i class="slider-dice__pip" style="grid-area:1 / 1"></i>
<i class="slider-dice__pip" style="grid-area:1 / 3"></i>
<i class="slider-dice__pip" style="grid-area:3 / 1"></i>
<i class="slider-dice__pip" style="grid-area:3 / 3"></i>
</span>
</span>
</span>
</span>
</div>
<div class="slider-dice__track">
<span class="slider-dice__rail" aria-hidden="true"></span>
<span class="slider-dice__stop" style="left:calc(13px + 0 * (100% - 26px))" aria-hidden="true">1</span>
<span class="slider-dice__stop" style="left:calc(13px + 0.2 * (100% - 26px))" aria-hidden="true">2</span>
<span class="slider-dice__stop" style="left:calc(13px + 0.4 * (100% - 26px))" aria-hidden="true">3</span>
<span class="slider-dice__stop" data-on="" style="left:calc(13px + 0.6 * (100% - 26px))" aria-hidden="true">4</span>
<span class="slider-dice__stop" style="left:calc(13px + 0.8 * (100% - 26px))" aria-hidden="true">5</span>
<span class="slider-dice__stop" style="left:calc(13px + 1 * (100% - 26px))" aria-hidden="true">6</span>
<span class="slider-dice__thumb" aria-hidden="true"></span>
<input class="slider-dice__input" type="range" min="1" max="6" aria-label="Number" aria-valuetext="4" value="4"/>
</div>
</div>.slider-dice { --bone: light-dark(#fbfcfd, #e5e6e9); --pip: light-dark(#373a40, #121417); display: grid; gap: 14px; width: 230px; }
.slider-dice__table { position: relative; height: 118px; perspective: 520px; }
.slider-dice__shadow { position: absolute; left: 50%; top: 96px; width: 74px; height: 14px; translate: -50% 0; border-radius: 50%; box-shadow: inset 0 0 0 40px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); filter: blur(4px); }
.slider-dice__hop { position: absolute; left: 50%; top: 22px; width: 60px; height: 60px; margin-left: -30px; transform-style: preserve-3d; }
.slider-dice[data-roll='a'] .slider-dice__hop { animation: slider-dice-hop-a 0.7s ease-out; }
.slider-dice[data-roll='b'] .slider-dice__hop { animation: slider-dice-hop-b 0.7s ease-out; }
.slider-dice[data-roll='a'] .slider-dice__shadow { animation: slider-dice-shadow-a 0.7s ease-out; }
.slider-dice[data-roll='b'] .slider-dice__shadow { animation: slider-dice-shadow-b 0.7s ease-out; }
/* Seen from a little above and to the side, so three faces show. */
.slider-dice__view { position: absolute; inset: 0; transform-style: preserve-3d; transform: rotateX(-24deg) rotateY(-32deg); }
.slider-dice__cube { position: absolute; inset: 0; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.25, 0.9, 0.3, 1.06); }
.slider-dice__face { position: absolute; inset: 0; display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr); padding: 9px; box-sizing: border-box; border-radius: 11px; background: radial-gradient(circle at 30% 25%, var(--bone), color-mix(in oklab, var(--bone) 88%, light-dark(#6d7179, #7d8087))); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--bone) 80%, light-dark(#6d7179, #7d8087)), inset 0 -3px 6px color-mix(in oklab, light-dark(#6d7179, #7d8087) 30%, transparent); backface-visibility: hidden; }
/* A core just inside the faces fills the rounded corners. */
.slider-dice__core { position: absolute; inset: 3px; background: color-mix(in oklab, var(--bone) 85%, light-dark(#6d7179, #7d8087)); }
.slider-dice__pip { place-self: center; width: 10px; height: 10px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, color-mix(in oklab, var(--pip) 70%, var(--bone)), var(--pip) 70%); }
.slider-dice__track { position: relative; height: 40px; }
.slider-dice__rail { position: absolute; left: 13px; right: 13px; top: 11px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-dice__stop { position: absolute; top: 24px; translate: -50% 0; color: light-dark(#6d7179, #7d8087); font-size: 10px; font-weight: 600; transition: color 0.2s; }
.slider-dice__stop[data-on] { color: light-dark(#373a40, #f1f2f3); }
.slider-dice__thumb { position: absolute; top: 0; left: calc(var(--slider-dice-v) * (100% - 26px)); width: 26px; height: 26px; border-radius: 7px; background: var(--bone); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px color-mix(in oklab, var(--bone) 75%, light-dark(#6d7179, #7d8087)); transition: left 0.25s cubic-bezier(0.3, 1.3, 0.5, 1), rotate 0.25s; rotate: calc(var(--slider-dice-v) * 450deg); }
.slider-dice__thumb::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; translate: -50% -50%; border-radius: 50%; background: var(--pip); }
.slider-dice:has(.slider-dice__input:focus-visible) .slider-dice__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-dice__input { position: absolute; left: 0; top: 0; width: 100%; height: 26px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-dice__input::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; }
.slider-dice__input::-moz-range-thumb { width: 26px; height: 26px; border: 0; }
@keyframes slider-dice-hop-a { 0% { transform: translateY(0); } 35% { transform: translateY(-30px); } 70% { transform: translateY(0); } 82% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
@keyframes slider-dice-hop-b { 0% { transform: translateY(0); } 35% { transform: translateY(-30px); } 70% { transform: translateY(0); } 82% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
@keyframes slider-dice-shadow-a { 35% { scale: 0.6; opacity: 0.5; } 70%, 100% { scale: 1; opacity: 1; } }
@keyframes slider-dice-shadow-b { 35% { scale: 0.6; opacity: 0.5; } 70%, 100% { scale: 1; opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.slider-dice * { animation: none !important; transition: none !important; }
}function initSliderDice(root) {
var input = root.querySelector('.slider-dice__input');
var cube = root.querySelector('.slider-dice__cube');
var stops = root.querySelectorAll('.slider-dice__stop');
// The turn that brings each face to the front: 1 front, 6 back, 2 top, 5 bottom, 3 right, 4 left.
var show = { 1: [0, 0], 6: [0, 180], 2: [-90, 0], 5: [90, 0], 3: [0, -90], 4: [0, 90] };
var roll = 0;
function update(rolled) {
var n = +input.value;
if (rolled) roll += 1;
// Always forward: every roll adds a whole turn on one axis or the other.
cube.style.transform = 'rotateX(' + (show[n][0] - 360 * Math.ceil(roll / 2)) + 'deg) rotateY(' + (show[n][1] + 360 * Math.floor(roll / 2)) + 'deg)';
// Switching between two identical animations replays the hop on every roll.
if (roll) root.setAttribute('data-roll', roll % 2 ? 'a' : 'b');
stops.forEach(function (stop, i) { stop.toggleAttribute('data-on', i + 1 === n); });
root.style.setProperty('--slider-dice-v', (n - 1) / 5);
input.setAttribute('aria-valuetext', String(n));
}
function onInput() { update(true); }
input.addEventListener('input', onInput);
update(false);
return function () { input.removeEventListener('input', onInput); };
}
document.querySelectorAll('.slider-dice').forEach(initSliderDice);Goal: Create a "Dice" slider.
Visual behavior: Pick a number from one to six and the die tumbles over in 3D to land on that face, with a hop and its shadow.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- Six faces in preserve-3d, opposite ones adding to seven, with a core inside to fill the rounded corners. It is seen from a little above and to the side, so three faces show.
- Every roll adds a whole turn on one axis or the other before turning to the new face, so the die always tumbles forward.
- A hop and its shadow replay on each roll by switching between two identical animations, and the handle turns a quarter per step like a die rolling along.
Accessibility: A native range input named "Number" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the die turns straight to the face.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Dice" slider.
Visual behavior: Pick a number from one to six and the die tumbles over in 3D to land on that face, with a hop and its shadow.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- Six faces in preserve-3d, opposite ones adding to seven, with a core inside to fill the rounded corners. It is seen from a little above and to the side, so three faces show.
- Every roll adds a whole turn on one axis or the other before turning to the new face, so the die always tumbles forward.
- A hop and its shadow replay on each roll by switching between two identical animations, and the handle turns a quarter per step like a die rolling along.
Accessibility: A native range input named "Number" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the die turns straight to the face.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Pick a number from one to six and the die tumbles over in 3D to land on that face, with a hop and its shadow.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, 3d, spring
- Techniques
- preserve 3d, 3d transform, perspective, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Number" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the die turns straight to the face.