Abacus

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

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

Install with shadcn

npx shadcn@latest add @movin/loader-abacus
Source on GitHub

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.

More loaders

See all
Barber pole
Ferris wheel
Geneva drive
Hamster