Dice

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-dice
Source on GitHub

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.

More sliders

See all
Dive
Egg
Flipbook
Grind