Abacus
import { useEffect, useRef, useState } from 'react';
const W = 240;
const N = 10;
const BW = 20;
const GAP = W - N * BW;
const MID = 18;
const BEAD = 'M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z';
const css = `
.slider-abacus { position: relative; width: ${W}px; height: 60px; }
.slider-abacus__rod { position: absolute; left: -6px; right: -6px; top: ${MID - 0.75}px; height: 1.5px; background: light-dark(#cbced3, #393c41); }
.slider-abacus__post { position: absolute; top: ${MID - 18}px; width: 4px; height: 36px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.slider-abacus__bead {
position: absolute;
left: 1px;
top: ${MID - 14}px;
width: 18px;
height: 28px;
/* Ends in a small rebound, the knock of one bead against the next. */
transition: transform 0.42s linear(0, 0.34 18%, 0.78 40%, 1 60%, 0.975 72%, 1 86%, 1);
}
.slider-abacus__bead svg { display: block; overflow: visible; }
.slider-abacus__body { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; transition: fill 0.3s, stroke 0.3s; }
.slider-abacus__bead--on .slider-abacus__body { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#42454c, #e5e6e9); }
.slider-abacus__shine { fill: light-dark(#fbfcfd, #a7abb2); opacity: 0.5; }
.slider-abacus__grip {
position: absolute;
top: ${MID - 9}px;
left: calc(var(--slider-abacus-v) * ${BW}px + ${GAP / 2 - 1.5}px);
width: 3px;
height: 18px;
border-radius: 2px;
background: light-dark(#6d7179, #7d8087);
transition: left 0.42s cubic-bezier(0.25, 1, 0.35, 1), background-color 0.2s;
}
.slider-abacus:has(.slider-abacus__input:active) .slider-abacus__grip { background: light-dark(#373a40, #f1f2f3); }
.slider-abacus:has(.slider-abacus__input:focus-visible) .slider-abacus__grip { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-abacus__count { position: absolute; left: 0; right: 0; top: 42px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-abacus__count b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-abacus__input { position: absolute; left: 0; top: 0; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-abacus__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${GAP}px; height: 36px; }
.slider-abacus__input::-moz-range-thumb { width: ${GAP}px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-abacus__bead, .slider-abacus__grip { transition: none; }
}
`;
export function AbacusSlider() {
const [v, setV] = useState(6);
const prev = useRef(v);
const from = prev.current;
useEffect(() => {
prev.current = v;
}, [v]);
return (
<>
<style>
{css}
</style>
<div className="slider-abacus" style={{ '--slider-abacus-v': v } as React.CSSProperties}>
<span className="slider-abacus__rod" aria-hidden="true" />
<span className="slider-abacus__post" style={{ left: -10 }} aria-hidden="true" />
<span className="slider-abacus__post" style={{ right: -10 }} aria-hidden="true" />
{Array.from({ length: N }, (_, i) => {
const x = i < v ? i * BW : W - (N - i) * BW;
// The beads that cross set off one after another, the nearest first.
const delay = v > from && i >= from && i < v ? (i - from) * 32 : v < from && i >= v && i < from ? (from - 1 - i) * 32 : 0;
return (
<span
key={i}
className={`slider-abacus__bead${i < v ? ' slider-abacus__bead--on' : ''}`}
style={{ transform: `translateX(${x}px)`, transitionDelay: `${delay}ms` }}
aria-hidden="true"
>
<svg width={18} height={28} viewBox="0 0 18 28">
<path className="slider-abacus__body" d={BEAD} />
<ellipse className="slider-abacus__shine" cx={6} cy={10} rx={1.6} ry={4} />
</svg>
</span>
);
})}
<span className="slider-abacus__grip" aria-hidden="true" />
<p className="slider-abacus__count" aria-hidden="true">
<span>Glasses of water</span>
<span>
<b>{v}</b> of {N}
</span>
</p>
<input
className="slider-abacus__input"
type="range"
min={0}
max={N}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Glasses of water"
aria-valuetext={`${v} of ${N}`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderAbacus(el) on the mounted element instead.
<div class="slider-abacus" style="--slider-abacus-v:6">
<span class="slider-abacus__rod" aria-hidden="true"></span>
<span class="slider-abacus__post" style="left:-10px" aria-hidden="true"></span>
<span class="slider-abacus__post" style="right:-10px" aria-hidden="true"></span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(0px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(20px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(40px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(60px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(80px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead slider-abacus__bead--on" style="transform:translateX(100px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead" style="transform:translateX(160px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead" style="transform:translateX(180px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead" style="transform:translateX(200px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__bead" style="transform:translateX(220px);transition-delay:0ms" aria-hidden="true">
<svg width="18" height="28" viewBox="0 0 18 28">
<path class="slider-abacus__body" d="M9 0C12 0 18 10 18 14S12 28 9 28 0 18 0 14 6 0 9 0Z"></path>
<ellipse class="slider-abacus__shine" cx="6" cy="10" rx="1.6" ry="4"></ellipse>
</svg>
</span>
<span class="slider-abacus__grip" aria-hidden="true"></span>
<p class="slider-abacus__count" aria-hidden="true">
<span>Glasses of water</span>
<span>
<b>6</b>
of 10
</span>
</p>
<input class="slider-abacus__input" type="range" min="0" max="10" aria-label="Glasses of water" aria-valuetext="6 of 10" value="6"/>
</div>.slider-abacus { position: relative; width: 240px; height: 60px; }
.slider-abacus__rod { position: absolute; left: -6px; right: -6px; top: 17.25px; height: 1.5px; background: light-dark(#cbced3, #393c41); }
.slider-abacus__post { position: absolute; top: 0px; width: 4px; height: 36px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.slider-abacus__bead {
position: absolute;
left: 1px;
top: 4px;
width: 18px;
height: 28px;
/* Ends in a small rebound, the knock of one bead against the next. */
transition: transform 0.42s linear(0, 0.34 18%, 0.78 40%, 1 60%, 0.975 72%, 1 86%, 1);
}
.slider-abacus__bead svg { display: block; overflow: visible; }
.slider-abacus__body { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; transition: fill 0.3s, stroke 0.3s; }
.slider-abacus__bead--on .slider-abacus__body { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#42454c, #e5e6e9); }
.slider-abacus__shine { fill: light-dark(#fbfcfd, #a7abb2); opacity: 0.5; }
.slider-abacus__grip {
position: absolute;
top: 9px;
left: calc(var(--slider-abacus-v) * 20px + 18.5px);
width: 3px;
height: 18px;
border-radius: 2px;
background: light-dark(#6d7179, #7d8087);
transition: left 0.42s cubic-bezier(0.25, 1, 0.35, 1), background-color 0.2s;
}
.slider-abacus:has(.slider-abacus__input:active) .slider-abacus__grip { background: light-dark(#373a40, #f1f2f3); }
.slider-abacus:has(.slider-abacus__input:focus-visible) .slider-abacus__grip { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-abacus__count { position: absolute; left: 0; right: 0; top: 42px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-abacus__count b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-abacus__input { position: absolute; left: 0; top: 0; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-abacus__input::-webkit-slider-thumb { -webkit-appearance: none; width: 40px; height: 36px; }
.slider-abacus__input::-moz-range-thumb { width: 40px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-abacus__bead, .slider-abacus__grip { transition: none; }
}function initSliderAbacus(root) {
var W = 240;
var BW = 20;
var input = root.querySelector('.slider-abacus__input');
var beads = root.querySelectorAll('.slider-abacus__bead');
var count = root.querySelector('.slider-abacus__count b');
var N = beads.length;
var from = +input.value;
function render() {
var v = +input.value;
root.style.setProperty('--slider-abacus-v', v);
beads.forEach(function (bead, i) {
// The beads that cross set off one after another, the nearest first.
var delay = v > from && i >= from && i < v ? (i - from) * 32 : v < from && i >= v && i < from ? (from - 1 - i) * 32 : 0;
bead.style.transitionDelay = delay + 'ms';
bead.style.transform = 'translateX(' + (i < v ? i * BW : W - (N - i) * BW) + 'px)';
bead.classList.toggle('slider-abacus__bead--on', i < v);
});
count.textContent = v;
input.setAttribute('aria-valuetext', v + ' of ' + N);
from = v;
}
input.addEventListener('input', render);
return function () {
input.removeEventListener('input', render);
};
}
document.querySelectorAll('.slider-abacus').forEach(initSliderAbacus);Goal: Create a "Abacus" slider.
Visual behavior: A counter of ten beads on a rod: drag the gap and the beads slide across one after another, knocking to a stop against the row with a small rebound.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area exactly as wide as the gap between the two groups of beads, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Counted beads pack from the left and the rest from the right, so the gap lands exactly where the native thumb is. Each bead moves with a transform transition whose linear() easing ends in a small rebound.
- When the value changes, the beads that cross get transition delays in order, nearest first, so they set off one after another. A grip mark sits in the gap.
Accessibility: A native range input from 0 to 10 with an aria-label and an aria-valuetext of the count out of ten; keyboard focus rings the grip. Under prefers-reduced-motion beads move without easing.
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 "Abacus" slider.
Visual behavior: A counter of ten beads on a rod: drag the gap and the beads slide across one after another, knocking to a stop against the row with a small rebound.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area exactly as wide as the gap between the two groups of beads, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Counted beads pack from the left and the rest from the right, so the gap lands exactly where the native thumb is. Each bead moves with a transform transition whose linear() easing ends in a small rebound.
- When the value changes, the beads that cross get transition delays in order, nearest first, so they set off one after another. A grip mark sits in the gap.
Accessibility: A native range input from 0 to 10 with an aria-label and an aria-valuetext of the count out of ten; keyboard focus rings the grip. Under prefers-reduced-motion beads move without easing.
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 counter of ten beads on a rod: drag the gap and the beads slide across one after another, knocking to a stop against the row with a small rebound.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, delay stagger, spring
- Techniques
- css transition, transition delay stagger, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input from 0 to 10 with an aria-label and an aria-valuetext of the count out of ten; keyboard focus rings the grip. Under prefers-reduced-motion beads move without easing.