Odometer
import { useEffect, useState } from 'react';
const css = `
.loader-odometer {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.loader-odometer__window {
display: flex;
gap: 2px;
padding: 4px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 6px 8px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
}
.loader-odometer__reel {
position: relative;
width: 20px;
height: 30px;
overflow: hidden;
border-radius: 4px;
background: light-dark(#f3f4f7, #1a1c1f);
/* Shade top and bottom like a drum curving away. */
mask-image: linear-gradient(transparent, #000 22%, #000 78%, transparent);
}
.loader-odometer__strip {
display: flex;
flex-direction: column;
transition: transform 0.32s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.loader-odometer__strip span {
display: grid;
place-items: center;
height: 30px;
color: light-dark(#373a40, #f1f2f3);
font-size: 18px;
font-weight: 600;
}
.loader-odometer__unit { align-self: center; padding: 0 3px 0 4px; color: light-dark(#5f626a, #a7abb2); font-size: 14px; }
.loader-odometer__label { font-size: 9px; letter-spacing: 0.3em; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) { .loader-odometer__strip { transition: none; } }
`;
// Each reel carries 0–9 and a second 0, so 9 → 0 can roll forward before snapping back.
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
function Reel({ value }: { value: number }) {
return (
<span className="loader-odometer__reel">
<span className="loader-odometer__strip" style={{ transform: `translateY(${-value * 30}px)` }}>
{DIGITS.map((d, i) => (
<span key={i}>{d}</span>
))}
</span>
</span>
);
}
export function OdometerLoader() {
const [n, setN] = useState(0);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setN(42);
// Count in uneven jumps, like a real transfer, then hold at 100 and start again.
const id = window.setInterval(() => setN((v) => (v >= 100 ? 0 : Math.min(100, v + 1 + Math.floor(Math.random() * 4)))), 140);
return () => clearInterval(id);
}, []);
const digits = String(n).padStart(3, '0').split('').map(Number);
return (
<>
<style>
{css}
</style>
<div className="loader-odometer" role="status" aria-label="Loading">
<span className="loader-odometer__label" aria-hidden="true">
LOADING
</span>
<span className="loader-odometer__window" aria-hidden="true">
{digits.map((d, i) => (
<Reel key={i} value={d} />
))}
<span className="loader-odometer__unit">%</span>
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderOdometer(el) on the mounted element instead.
<div class="loader-odometer" role="status" aria-label="Loading">
<span class="loader-odometer__label" aria-hidden="true">LOADING</span>
<span class="loader-odometer__window" aria-hidden="true">
<span class="loader-odometer__reel">
<span class="loader-odometer__strip" style="transform:translateY(0px)">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
<span>0</span>
</span>
</span>
<span class="loader-odometer__reel">
<span class="loader-odometer__strip" style="transform:translateY(0px)">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
<span>0</span>
</span>
</span>
<span class="loader-odometer__reel">
<span class="loader-odometer__strip" style="transform:translateY(0px)">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
<span>0</span>
</span>
</span>
<span class="loader-odometer__unit">%</span>
</span>
</div>.loader-odometer {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.loader-odometer__window {
display: flex;
gap: 2px;
padding: 4px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 6px 8px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
}
.loader-odometer__reel {
position: relative;
width: 20px;
height: 30px;
overflow: hidden;
border-radius: 4px;
background: light-dark(#f3f4f7, #1a1c1f);
/* Shade top and bottom like a drum curving away. */
mask-image: linear-gradient(transparent, #000 22%, #000 78%, transparent);
}
.loader-odometer__strip {
display: flex;
flex-direction: column;
transition: transform 0.32s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.loader-odometer__strip span {
display: grid;
place-items: center;
height: 30px;
color: light-dark(#373a40, #f1f2f3);
font-size: 18px;
font-weight: 600;
}
.loader-odometer__unit { align-self: center; padding: 0 3px 0 4px; color: light-dark(#5f626a, #a7abb2); font-size: 14px; }
.loader-odometer__label { font-size: 9px; letter-spacing: 0.3em; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) { .loader-odometer__strip { transition: none; } }function initLoaderOdometer(root) {
var strips = root.querySelectorAll('.loader-odometer__strip');
if (strips.length !== 3) return;
function show(n) {
String(n).padStart(3, '0').split('').forEach(function (d, i) {
strips[i].style.transform = 'translateY(' + -Number(d) * 30 + 'px)';
});
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
show(42);
return;
}
var n = 0;
// Count in uneven jumps, like a real transfer, then hold at 100 and start again.
var timer = setInterval(function () {
n = n >= 100 ? 0 : Math.min(100, n + 1 + Math.floor(Math.random() * 4));
show(n);
}, 140);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.loader-odometer').forEach(initLoaderOdometer);Goal: Create a "Odometer" loader.
Visual behavior: Rolling digit reels count from 000 to 100 percent in uneven jumps, like a real transfer; each reel rolls forward through its digits with a little bounce, then the count holds and starts over.
Technique:
- Each reel is a 30px window over a strip of digits 0 to 9 plus a second 0, with a top and bottom fade (mask) so it reads as a drum.
- Showing digit d means translating the strip by -30d px with a slightly overshooting transition, so every change rolls.
- An interval adds 1 to 4 each tick until 100, then resets to 0.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion it shows a still 042.
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 "Odometer" loader.
Visual behavior: Rolling digit reels count from 000 to 100 percent in uneven jumps, like a real transfer; each reel rolls forward through its digits with a little bounce, then the count holds and starts over.
Technique:
- Each reel is a 30px window over a strip of digits 0 to 9 plus a second 0, with a top and bottom fade (mask) so it reads as a drum.
- Showing digit d means translating the strip by -30d px with a slightly overshooting transition, so every change rolls.
- An interval adds 1 to 4 each tick until 100, then resets to 0.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion it shows a still 042.
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
Rolling digit reels count from 000 to 100 percent in uneven jumps, like a real transfer; each reel rolls forward through its digits with a little bounce, then the count holds and starts over.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, spring
- Techniques
- interval, css transition, transform only
- 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 it shows a still 042.