Abacus
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
.loader-abacus { position: relative; width: 136px; height: 97px; }
.loader-abacus svg { width: 100%; height: 100%; overflow: visible; }
.loader-abacus__frame { fill: light-dark(#cbced3, #393c41); }
.loader-abacus__back { fill: light-dark(#f3f4f7, #1a1c1f); }
.loader-abacus__rod { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
/* A bead clacks across with a little overshoot; a rod emptying back slides its beads home in turn. */
.loader-abacus__bead { transition: transform 0.26s cubic-bezier(0.3, 1.35, 0.5, 1) calc(var(--i) * 25ms); }
.loader-abacus__bead[data-on] { transform: translateX(-48px); }
.loader-abacus__bead path { stroke: light-dark(#fbfcfd, #121417); stroke-width: 0.8; }
.loader-abacus__shine { fill: none; stroke: light-dark(#fbfcfd, #121417); stroke-width: 1; opacity: 0.35; stroke-linecap: round; }
@media (prefers-reduced-motion: reduce) {
.loader-abacus__bead { transition: none; }
}
`;
// Base five: four beads a rod. The bottom rod counts every tick and carries into the rods above.
const RODS = [
{ y: 66, step: 1, fill: 'light-dark(#42454c, #e5e6e9)' },
{ y: 44, step: 5, fill: 'light-dark(#5f626a, #a7abb2)' },
{ y: 22, step: 25, fill: 'light-dark(#6d7179, #7d8087)' },
];
const BEAD = 'M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z';
export function AbacusLoader() {
const [n, setN] = useState(0);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const t = setInterval(() => setN((v) => (v + 1) % 125), 340);
return () => clearInterval(t);
}, []);
return (
<>
<style>
{css}
</style>
<div className="loader-abacus" role="status" aria-label="Loading">
<svg viewBox="0 0 124 88" aria-hidden="true">
<rect className="loader-abacus__frame" x="0" y="2" width="124" height="84" rx="7" />
<rect className="loader-abacus__back" x="7" y="9" width="110" height="70" rx="3" />
{RODS.map((rod) => {
const k = Math.floor(n / rod.step) % 5;
return (
<g key={rod.y}>
<line className="loader-abacus__rod" x1="7" y1={rod.y} x2="117" y2={rod.y} />
{[0, 1, 2, 3].map((i) => (
<g key={i} transform={`translate(${68 + i * 13} ${rod.y})`}>
<g className="loader-abacus__bead" data-on={i < k || undefined} style={{ '--i': i < k ? i : 3 - i } as CSSProperties}>
<path d={BEAD} style={{ fill: rod.fill }} />
<path className="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1" />
</g>
</g>
))}
</g>
);
})}
</svg>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderAbacus(el) on the mounted element instead.
<div class="loader-abacus" role="status" aria-label="Loading">
<svg viewBox="0 0 124 88" aria-hidden="true">
<rect class="loader-abacus__frame" x="0" y="2" width="124" height="84" rx="7"></rect>
<rect class="loader-abacus__back" x="7" y="9" width="110" height="70" rx="3"></rect>
<g>
<line class="loader-abacus__rod" x1="7" y1="66" x2="117" y2="66"></line>
<g transform="translate(68 66)">
<g class="loader-abacus__bead" style="--i:3">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#42454c, #e5e6e9)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(81 66)">
<g class="loader-abacus__bead" style="--i:2">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#42454c, #e5e6e9)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(94 66)">
<g class="loader-abacus__bead" style="--i:1">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#42454c, #e5e6e9)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(107 66)">
<g class="loader-abacus__bead" style="--i:0">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#42454c, #e5e6e9)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
</g>
<g>
<line class="loader-abacus__rod" x1="7" y1="44" x2="117" y2="44"></line>
<g transform="translate(68 44)">
<g class="loader-abacus__bead" style="--i:3">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#5f626a, #a7abb2)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(81 44)">
<g class="loader-abacus__bead" style="--i:2">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#5f626a, #a7abb2)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(94 44)">
<g class="loader-abacus__bead" style="--i:1">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#5f626a, #a7abb2)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(107 44)">
<g class="loader-abacus__bead" style="--i:0">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#5f626a, #a7abb2)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
</g>
<g>
<line class="loader-abacus__rod" x1="7" y1="22" x2="117" y2="22"></line>
<g transform="translate(68 22)">
<g class="loader-abacus__bead" style="--i:3">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#6d7179, #7d8087)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(81 22)">
<g class="loader-abacus__bead" style="--i:2">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#6d7179, #7d8087)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(94 22)">
<g class="loader-abacus__bead" style="--i:1">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#6d7179, #7d8087)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
<g transform="translate(107 22)">
<g class="loader-abacus__bead" style="--i:0">
<path d="M-6 0Q-6 -3 -2.5 -6.5H2.5Q6 -3 6 0Q6 3 2.5 6.5H-2.5Q-6 3 -6 0Z" style="fill:light-dark(#6d7179, #7d8087)"></path>
<path class="loader-abacus__shine" d="M-3 -4Q-4.5 -1 -4 1"></path>
</g>
</g>
</g>
</svg>
</div>.loader-abacus { position: relative; width: 136px; height: 97px; }
.loader-abacus svg { width: 100%; height: 100%; overflow: visible; }
.loader-abacus__frame { fill: light-dark(#cbced3, #393c41); }
.loader-abacus__back { fill: light-dark(#f3f4f7, #1a1c1f); }
.loader-abacus__rod { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
/* A bead clacks across with a little overshoot; a rod emptying back slides its beads home in turn. */
.loader-abacus__bead { transition: transform 0.26s cubic-bezier(0.3, 1.35, 0.5, 1) calc(var(--i) * 25ms); }
.loader-abacus__bead[data-on] { transform: translateX(-48px); }
.loader-abacus__bead path { stroke: light-dark(#fbfcfd, #121417); stroke-width: 0.8; }
.loader-abacus__shine { fill: none; stroke: light-dark(#fbfcfd, #121417); stroke-width: 1; opacity: 0.35; stroke-linecap: round; }
@media (prefers-reduced-motion: reduce) {
.loader-abacus__bead { transition: none; }
}function initLoaderAbacus(root) {
// Base five: four beads a rod. The bottom rod counts every tick and carries into the rods above.
var STEPS = [1, 5, 25];
var rods = root.querySelectorAll('svg > g');
var n = 0;
function show() {
rods.forEach(function (rod, r) {
var k = Math.floor(n / STEPS[r]) % 5;
rod.querySelectorAll('.loader-abacus__bead').forEach(function (bead, i) {
bead.toggleAttribute('data-on', i < k);
bead.style.setProperty('--i', i < k ? i : 3 - i);
});
});
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var timer = setInterval(function () {
n = (n + 1) % 125;
show();
}, 340);
return function () {
clearInterval(timer);
};
}
document.querySelectorAll('.loader-abacus').forEach(initLoaderAbacus);Goal: Create a "Abacus" loader.
Visual behavior: An abacus counting: a bead clicks across the bottom rod at every tick, and when the rod fills its beads slide home and one bead carries on the rod above.
Technique:
- A counter steps every 340ms. Counting in fives, each rod has four beads: the bottom rod counts ones, the next fives and the top twenty-fives.
- A bead slides with a transition on a small overshoot so it clacks, and a rod emptying back sends its beads home one after another.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the counter does not run.
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" loader.
Visual behavior: An abacus counting: a bead clicks across the bottom rod at every tick, and when the rod fills its beads slide home and one bead carries on the rod above.
Technique:
- A counter steps every 340ms. Counting in fives, each rod has four beads: the bottom rod counts ones, the next fives and the top twenty-fives.
- A bead slides with a transition on a small overshoot so it clacks, and a rod emptying back sends its beads home one after another.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the counter does not run.
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
An abacus counting: a bead clicks across the bottom rod at every tick, and when the rod fills its beads slide home and one bead carries on the rod above.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, spring
- Techniques
- interval, css transition, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the counter does not run.