Domino
import { useEffect, useRef, useState } from 'react';
const W = 240;
const N = 16;
const S = 14;
const DW = 5.5;
const DH = 22;
const BASE = 34;
const T = 14;
const dx = (i: number) => 12 + i * S;
const at = (k: number) => T / 2 + (k / N) * (W - T);
const css = `
.slider-domino { position: relative; width: ${W}px; height: 76px; }
.slider-domino__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 54px; overflow: visible; }
.slider-domino__tile {
fill: light-dark(#cbced3, #393c41);
transform-box: fill-box;
transform-origin: 100% 100%;
transition: transform 0.3s cubic-bezier(0.55, 0, 0.9, 0.55), fill 0.3s;
}
.slider-domino__tile--down { fill: light-dark(#42454c, #e5e6e9); }
.slider-domino__floor { stroke: light-dark(#ebedf0, #232529); stroke-width: 1.5; }
.slider-domino__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 3; stroke-linecap: round; }
.slider-domino__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 3; stroke-linecap: round; }
.slider-domino__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-domino__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-domino:has(.slider-domino__input:focus-visible) .slider-domino__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-domino__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-domino__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-domino__input { position: absolute; left: 0; top: 0; width: 100%; height: 54px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-domino__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 54px; }
.slider-domino__input::-moz-range-thumb { width: ${T}px; height: 54px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-domino__tile { transition: none; }
}
`;
export function DominoSlider() {
const [k, setK] = useState(9);
const prev = useRef(9);
useEffect(() => {
prev.current = k;
}, [k]);
const was = prev.current;
return (
<>
<style>
{css}
</style>
<div className="slider-domino">
<svg className="slider-domino__svg" aria-hidden="true">
<line className="slider-domino__floor" x1={4} y1={BASE + 0.75} x2={W - 4} y2={BASE + 0.75} />
{Array.from({ length: N }, (_, i) => {
const down = i < k;
// The last one down leans on the first one standing; the rest lie on each other.
const tilt = !down ? 0 : i === k - 1 && k < N ? 23 : 64;
// A jump of several topples them one after another, and stands them up the same way back.
const delay = down && i >= was ? (i - was) * 45 : !down && i < was ? (was - 1 - i) * 45 : 0;
return (
<rect
key={i}
className={`slider-domino__tile${down ? ' slider-domino__tile--down' : ''}`}
x={dx(i)}
y={BASE - DH}
width={DW}
height={DH}
rx={1.5}
style={{ transform: `rotate(${tilt}deg)`, transitionDelay: `${delay}ms` }}
/>
);
})}
<line className="slider-domino__rail" x1={T / 2} y1={BASE + 10} x2={W - T / 2} y2={BASE + 10} />
<line className="slider-domino__fill" x1={T / 2} y1={BASE + 10} x2={at(k)} y2={BASE + 10} />
<circle className="slider-domino__thumb" cx={at(k)} cy={BASE + 10} r={T / 2} />
<circle className="slider-domino__ring" cx={at(k)} cy={BASE + 10} r={T / 2 + 4} />
</svg>
<p className="slider-domino__value" aria-hidden="true">
<span>Tasks done</span>
<b>
{k} / {N}
</b>
</p>
<input
className="slider-domino__input"
type="range"
min={0}
max={N}
value={k}
onChange={(e) => setK(+e.target.value)}
aria-label="Tasks done"
aria-valuetext={`${k} of ${N}`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDomino(el) on the mounted element instead.
<div class="slider-domino">
<svg class="slider-domino__svg" aria-hidden="true">
<line class="slider-domino__floor" x1="4" y1="34.75" x2="236" y2="34.75"></line>
<rect class="slider-domino__tile slider-domino__tile--down" x="12" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="26" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="40" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="54" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="68" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="82" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="96" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="110" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(64deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile slider-domino__tile--down" x="124" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(23deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="138" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="152" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="166" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="180" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="194" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="208" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<rect class="slider-domino__tile" x="222" y="12" width="5.5" height="22" rx="1.5" style="transform:rotate(0deg);transition-delay:0ms"></rect>
<line class="slider-domino__rail" x1="7" y1="44" x2="233" y2="44"></line>
<line class="slider-domino__fill" x1="7" y1="44" x2="134.125" y2="44"></line>
<circle class="slider-domino__thumb" cx="134.125" cy="44" r="7"></circle>
<circle class="slider-domino__ring" cx="134.125" cy="44" r="11"></circle>
</svg>
<p class="slider-domino__value" aria-hidden="true">
<span>Tasks done</span>
<b>9 / 16</b>
</p>
<input class="slider-domino__input" type="range" min="0" max="16" aria-label="Tasks done" aria-valuetext="9 of 16" value="9"/>
</div>.slider-domino { position: relative; width: 240px; height: 76px; }
.slider-domino__svg { position: absolute; left: 0; top: 0; width: 240px; height: 54px; overflow: visible; }
.slider-domino__tile {
fill: light-dark(#cbced3, #393c41);
transform-box: fill-box;
transform-origin: 100% 100%;
transition: transform 0.3s cubic-bezier(0.55, 0, 0.9, 0.55), fill 0.3s;
}
.slider-domino__tile--down { fill: light-dark(#42454c, #e5e6e9); }
.slider-domino__floor { stroke: light-dark(#ebedf0, #232529); stroke-width: 1.5; }
.slider-domino__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 3; stroke-linecap: round; }
.slider-domino__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 3; stroke-linecap: round; }
.slider-domino__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-domino__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-domino:has(.slider-domino__input:focus-visible) .slider-domino__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-domino__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-domino__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-domino__input { position: absolute; left: 0; top: 0; width: 100%; height: 54px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-domino__input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 54px; }
.slider-domino__input::-moz-range-thumb { width: 14px; height: 54px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-domino__tile { transition: none; }
}function initSliderDomino(root) {
var W = 240;
var N = 16;
var T = 14;
var input = root.querySelector('.slider-domino__input');
var tiles = root.querySelectorAll('.slider-domino__tile');
var fill = root.querySelector('.slider-domino__fill');
var thumb = root.querySelector('.slider-domino__thumb');
var ring = root.querySelector('.slider-domino__ring');
var label = root.querySelector('.slider-domino__value b');
var was = +input.value;
function at(k) { return T / 2 + (k / N) * (W - T); }
function update() {
var k = +input.value;
tiles.forEach(function (t, i) {
var down = i < k;
// The last one down leans on the first one standing; the rest lie on each other.
var tilt = !down ? 0 : i === k - 1 && k < N ? 23 : 64;
// A jump of several topples them one after another, and stands them up the same way back.
var delay = down && i >= was ? (i - was) * 45 : !down && i < was ? (was - 1 - i) * 45 : 0;
t.classList.toggle('slider-domino__tile--down', down);
t.style.transitionDelay = delay + 'ms';
t.style.transform = 'rotate(' + tilt + 'deg)';
});
was = k;
fill.setAttribute('x2', at(k));
thumb.setAttribute('cx', at(k));
ring.setAttribute('cx', at(k));
label.textContent = k + ' / ' + N;
input.setAttribute('aria-valuetext', k + ' of ' + N);
}
input.addEventListener('input', update);
return function () {
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-domino').forEach(initSliderDomino);Goal: Create a "Domino" slider.
Visual behavior: A count slider whose track is a row of dominoes: drag forward and they topple one into the next to make the fill, drag back and they stand up again.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each domino rotates about its bottom right corner (transform-box fill-box). Fallen ones lie at 64 degrees on each other, and the last one down leans at 23 degrees on the first one standing. The rotation eases in, like falling.
- When the value jumps, each changing domino gets a transition delay from its distance to the old value, so a run topples in order and stands back up in reverse.
Accessibility: A native range input with an aria-label and an aria-valuetext like 9 of 16; keyboard focus rings the thumb. Under prefers-reduced-motion the dominoes fall without the transition.
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 "Domino" slider.
Visual behavior: A count slider whose track is a row of dominoes: drag forward and they topple one into the next to make the fill, drag back and they stand up again.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each domino rotates about its bottom right corner (transform-box fill-box). Fallen ones lie at 64 degrees on each other, and the last one down leans at 23 degrees on the first one standing. The rotation eases in, like falling.
- When the value jumps, each changing domino gets a transition delay from its distance to the old value, so a run topples in order and stands back up in reverse.
Accessibility: A native range input with an aria-label and an aria-valuetext like 9 of 16; keyboard focus rings the thumb. Under prefers-reduced-motion the dominoes fall without the transition.
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
A count slider whose track is a row of dominoes: drag forward and they topple one into the next to make the fill, drag back and they stand up again.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, delay stagger
- Techniques
- svg, css transition, transition delay stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext like 9 of 16; keyboard focus rings the thumb. Under prefers-reduced-motion the dominoes fall without the transition.