CSS code
Edit on CodePen ↗
StackBlitz ↗
HTML
Copy
<div><span class="lbl">Rolling odometer</span>
<section class="od od-box od-roll" aria-labelledby="od-roll-title">
<h3 class="od-eyebrow" id="od-roll-title">Pull requests reviewed by Patchwork</h3>
<p><span data-odometer>1,284,302</span></p>
<p class="od-note">since the first review in March 2021, across 9,400 repositories</p>
</section></div>
<div><span class="lbl">Mechanical odometer</span>
<section class="od od-box od-mech" aria-labelledby="od-mech-title">
<div><p class="od-eyebrow">Vela e-bus fleet</p><h3 id="od-mech-title">Kilometres driven since 2019</h3>
<p class="od-note">212 electric buses on 31 routes, charged overnight at four depots.</p></div>
<p class="od-housing"><span data-odometer>3,048,915</span><span class="od-unit">km</span></p>
</section></div>
<div><span class="lbl">Split-flap counter</span>
<section class="od od-box od-flap" aria-labelledby="od-flap-title">
<h3 class="od-eyebrow" id="od-flap-title">Tern Maps downloads in October</h3>
<p><span data-flip>48,230</span></p>
<p class="od-note">iOS and Android combined, counted at midnight UTC</p>
</section></div>
CSS
Copy
/* Odometer and flip counters. The HTML holds the plain number; the script turns it into digit reels
(.odo-*) or split-flap cards (.flip-*). Reel height and card height scale with the font size. */
.od{--od-ink:#1e1b2e;--od-muted:#59546e;--od-accent:#6d28d9;--od-h:1.15em;--od-card:#fff;color:var(--od-ink)}
.od-box{background:var(--od-card);border:1px solid #e4e0f2;border-radius:22px;padding:clamp(24px,4vw,40px);
box-shadow:0 14px 34px -20px rgba(49,30,110,.35)}
.od-eyebrow{font-size:.8rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--od-accent)}
.od-note{color:var(--od-muted);font-size:.92rem}
[data-odometer],[data-flip]{display:inline-block;font-variant-numeric:tabular-nums;white-space:nowrap}
/* digit reels */
.odo-face{display:inline-flex;line-height:var(--od-h)}
.odo-digit{display:inline-block;width:1ch;height:var(--od-h);overflow:hidden;text-align:center}
.odo-reel{display:block;white-space:pre;transform:translateY(calc(var(--i,0) * var(--od-h) * -1));
transition:transform 2.4s cubic-bezier(.16,1,.3,1) calc(var(--k,0) * 70ms)}
/* 1. Rolling odometer */
.od-roll{text-align:center}
.od-roll [data-odometer]{margin:14px 0 6px;font-size:clamp(2.8rem,9vw,5rem);font-weight:800;letter-spacing:-.02em;line-height:var(--od-h)}
.od-roll .odo-digit{-webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}
.od-roll .odo-sep{color:var(--od-accent)}
/* 2. Mechanical odometer: dark wheels, last wheel inverted */
.od-mech{display:flex;align-items:center;justify-content:space-between;gap:20px 32px;flex-wrap:wrap}
.od-mech h3{font-size:1.3rem;letter-spacing:-.01em;margin-top:4px}
.od-housing{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:14px;background:#14121c;color:#f5f3ff;
box-shadow:inset 0 2px 6px rgba(0,0,0,.6),0 1px 0 #fff}
.od-housing [data-odometer]{font:700 clamp(1.5rem,5vw,2.2rem)/var(--od-h) ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.od-housing .odo-face{gap:3px}
.od-housing .odo-digit{width:1.3ch;border-radius:4px;background:linear-gradient(#07060b,#2d2a3a 48%,#2d2a3a 52%,#07060b);
box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
.od-housing .odo-digit:last-child{background:linear-gradient(#cfcbe0,#fff 48%,#fff 52%,#cfcbe0);color:#14121c}
.od-housing .odo-sep{width:.4ch;color:#a8a3c0;text-align:center}
.od-unit{font-size:.82rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#c9c4e0}
/* 3. Split-flap counter */
.od-flap{text-align:center}
.od-flap [data-flip]{margin:16px 0 8px;font-size:clamp(2.2rem,8vw,3.6rem);font-weight:800}
.flip-face{display:inline-flex;align-items:flex-end;gap:.12em}
.flip-card{--fh:1.45em;position:relative;display:inline-block;width:1.02em;height:var(--fh);border-radius:.14em;color:#faf8ff;
background:#1d1a29;perspective:5em;box-shadow:0 .1em .24em rgba(20,10,50,.35)}
.flip-card::after{content:"";position:absolute;left:0;right:0;top:50%;height:2px;margin-top:-1px;background:#0d0b14;z-index:3}
.flip-half,.flip-flap{position:absolute;left:0;right:0;height:50%;overflow:hidden;backface-visibility:hidden}
.flip-half b,.flip-flap b{display:block;height:var(--fh);line-height:var(--fh);text-align:center;font-weight:inherit}
.flip-half.is-upper,.flip-flap.is-upper{top:0;border-radius:.14em .14em 0 0;background:#28243a}
.flip-half.is-lower,.flip-flap.is-lower{bottom:0;border-radius:0 0 .14em .14em;background:#1d1a29}
.flip-half.is-lower b,.flip-flap.is-lower b{transform:translateY(-50%)}
.flip-flap{visibility:hidden;z-index:2}
.flip-flap.is-upper{transform-origin:50% 100%}
.flip-flap.is-lower{transform-origin:50% 0;transform:rotateX(90deg)}
.flip-card.is-flipping .flip-flap{visibility:visible}
.flip-card.is-flipping .flip-flap.is-upper{animation:od-flip-top .16s ease-in forwards}
.flip-card.is-flipping .flip-flap.is-lower{animation:od-flip-bottom .16s .16s ease-out forwards}
.flip-sep{padding:0 .04em;color:var(--od-accent);line-height:1.2}
@keyframes od-flip-top{to{transform:rotateX(-90deg)}}
@keyframes od-flip-bottom{to{transform:rotateX(0)}}
@media (prefers-reduced-motion:reduce){.odo-reel{transition:none}.flip-card .flip-flap{animation:none;visibility:hidden}}
JavaScript
Copy
// Odometer reels and split-flap cards. The number is plain text in the HTML, so it reads correctly without
// JavaScript. The script draws the digits as reels ([data-odometer]) or flip cards ([data-flip]), hides that
// drawing from screen readers and keeps the real number in a visually hidden copy. The digits roll when the
// counter scrolls into view; under prefers-reduced-motion they are drawn at their final values straight away.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const make = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const onView = (el, start) => {
if (reduce || !('IntersectionObserver' in window)) return start(true);
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); start(false); } }, { threshold: 0.6 });
io.observe(el);
};
const mount = (el, face) => {
const copy = make('span', null, el.textContent.trim());
copy.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
face.setAttribute('aria-hidden', 'true');
el.replaceChildren(copy, face);
};
document.querySelectorAll('[data-odometer]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'odo-face'), reels = [];
let left = [...text].filter(c => c >= '0' && c <= '9').length;
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'odo-sep', ch));
left--;
const reel = make('span', 'odo-reel', '0123456789'.repeat(3).split('').join('\n'));
reel.style.setProperty('--k', reels.length);
reels.push([reel, +ch + 10 * (left < 2 ? 2 : left < 4 ? 1 : 0)]);
const win = make('span', 'odo-digit');
win.append(reel);
face.append(win);
});
mount(el, face);
onView(el, () => reels.forEach(([reel, i]) => reel.style.setProperty('--i', i)));
});
document.querySelectorAll('[data-flip]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'flip-face'), cards = [];
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'flip-sep', ch));
const card = make('span', 'flip-card');
for (const part of ['flip-half is-upper', 'flip-half is-lower', 'flip-flap is-upper', 'flip-flap is-lower']) {
const half = make('span', part);
half.append(make('b'));
card.append(half);
}
cards.push([card, +ch]);
face.append(card);
});
const show = (card, d) => card.querySelectorAll('b').forEach(b => { b.textContent = d; });
const flip = (card, from, to) => new Promise(done => {
const [up, low, top, bottom] = card.querySelectorAll('b');
up.textContent = to; low.textContent = from; top.textContent = from; bottom.textContent = to;
card.classList.remove('is-flipping'); void card.offsetWidth; card.classList.add('is-flipping');
setTimeout(() => { low.textContent = to; card.classList.remove('is-flipping'); done(); }, 330);
});
mount(el, face);
cards.forEach(([card, d], k) => show(card, reduce ? d : (d + 10 - 3 - k) % 10));
onView(el, instant => cards.forEach(async ([card, d], k) => {
if (instant) return show(card, d);
for (let s = 3 + k; s > 0; s--) await flip(card, (d + 10 - s) % 10, (d + 11 - s) % 10);
}));
});
})();
Bootstrap 5 code
Edit on CodePen ↗
StackBlitz ↗
Requires: Bootstrap 5.3.8 CSS
HTML
Copy
<div><span class="lbl">Rolling odometer</span>
<section class="od od-roll card text-center" aria-labelledby="od-roll-title"><div class="card-body">
<h3 class="od-eyebrow mb-0" id="od-roll-title">Pull requests reviewed by Patchwork</h3>
<p class="mt-3 mb-1"><span data-odometer>1,284,302</span></p>
<p class="text-body-secondary mb-0" style="font-size:.92rem">since the first review in March 2021, across 9,400 repositories</p>
</div></section></div>
<div><span class="lbl">Mechanical odometer</span>
<section class="od card" aria-labelledby="od-mech-title"><div class="card-body d-flex flex-wrap align-items-center justify-content-between column-gap-4 row-gap-3">
<div><p class="od-eyebrow mb-0">Vela e-bus fleet</p><h3 class="h5 fw-bold mt-1 mb-0" id="od-mech-title">Kilometres driven since 2019</h3>
<p class="text-body-secondary mb-0" style="font-size:.92rem">212 electric buses on 31 routes, charged overnight at four depots.</p></div>
<p class="od-housing mb-0"><span data-odometer>3,048,915</span><span class="od-unit">km</span></p>
</div></section></div>
<div><span class="lbl">Split-flap counter</span>
<section class="od od-flap card text-center" aria-labelledby="od-flap-title"><div class="card-body">
<h3 class="od-eyebrow mb-0" id="od-flap-title">Tern Maps downloads in October</h3>
<p class="mt-3 mb-2"><span data-flip>48,230</span></p>
<p class="text-body-secondary mb-0" style="font-size:.92rem">iOS and Android combined, counted at midnight UTC</p>
</div></section></div>
CSS
Copy
/* Odometer and flip counters on Bootstrap: .card for the panels, utilities for layout. The .odo-* and .flip-*
rules style the reels and cards that the script builds from the plain number in the HTML. */
.od{--od-ink:#1e1b2e;--od-accent:#6d28d9;--od-h:1.15em;--bs-secondary-color:#59546e;color:var(--od-ink)}
.od.card{--bs-card-border-color:#e4e0f2;--bs-card-border-radius:22px;box-shadow:0 14px 34px -20px rgba(49,30,110,.35)}
.od .card-body{padding:clamp(24px,4vw,40px)}
.od-eyebrow{font-size:.8rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--od-accent)}
[data-odometer],[data-flip]{display:inline-block;font-variant-numeric:tabular-nums;white-space:nowrap}
.odo-face{display:inline-flex;line-height:var(--od-h)}
.odo-digit{display:inline-block;width:1ch;height:var(--od-h);overflow:hidden;text-align:center}
.odo-reel{display:block;white-space:pre;transform:translateY(calc(var(--i,0) * var(--od-h) * -1));
transition:transform 2.4s cubic-bezier(.16,1,.3,1) calc(var(--k,0) * 70ms)}
.od-roll [data-odometer]{font-size:clamp(2.8rem,9vw,5rem);font-weight:800;letter-spacing:-.02em;line-height:var(--od-h)}
.od-roll .odo-digit{-webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}
.od-roll .odo-sep{color:var(--od-accent)}
.od-housing{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:14px;background:#14121c;color:#f5f3ff;
box-shadow:inset 0 2px 6px rgba(0,0,0,.6),0 1px 0 #fff}
.od-housing [data-odometer]{font:700 clamp(1.5rem,5vw,2.2rem)/var(--od-h) var(--bs-font-monospace)}
.od-housing .odo-face{gap:3px}
.od-housing .odo-digit{width:1.3ch;border-radius:4px;background:linear-gradient(#07060b,#2d2a3a 48%,#2d2a3a 52%,#07060b);box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
.od-housing .odo-digit:last-child{background:linear-gradient(#cfcbe0,#fff 48%,#fff 52%,#cfcbe0);color:#14121c}
.od-housing .odo-sep{width:.4ch;color:#a8a3c0;text-align:center}
.od-unit{font-size:.82rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#c9c4e0}
.od-flap [data-flip]{font-size:clamp(2.2rem,8vw,3.6rem);font-weight:800}
.flip-face{display:inline-flex;align-items:flex-end;gap:.12em}
.flip-card{--fh:1.45em;position:relative;display:inline-block;width:1.02em;height:var(--fh);border-radius:.14em;color:#faf8ff;
background:#1d1a29;perspective:5em;box-shadow:0 .1em .24em rgba(20,10,50,.35)}
.flip-card::after{content:"";position:absolute;left:0;right:0;top:50%;height:2px;margin-top:-1px;background:#0d0b14;z-index:3}
.flip-half,.flip-flap{position:absolute;left:0;right:0;height:50%;overflow:hidden;backface-visibility:hidden}
.flip-half b,.flip-flap b{display:block;height:var(--fh);line-height:var(--fh);text-align:center;font-weight:inherit}
.flip-half.is-upper,.flip-flap.is-upper{top:0;border-radius:.14em .14em 0 0;background:#28243a}
.flip-half.is-lower,.flip-flap.is-lower{bottom:0;border-radius:0 0 .14em .14em;background:#1d1a29}
.flip-half.is-lower b,.flip-flap.is-lower b{transform:translateY(-50%)}
.flip-flap{visibility:hidden;z-index:2}
.flip-flap.is-upper{transform-origin:50% 100%}
.flip-flap.is-lower{transform-origin:50% 0;transform:rotateX(90deg)}
.flip-card.is-flipping .flip-flap{visibility:visible}
.flip-card.is-flipping .flip-flap.is-upper{animation:od-flip-top .16s ease-in forwards}
.flip-card.is-flipping .flip-flap.is-lower{animation:od-flip-bottom .16s .16s ease-out forwards}
.flip-sep{padding:0 .04em;color:var(--od-accent);line-height:1.2}
@keyframes od-flip-top{to{transform:rotateX(-90deg)}}
@keyframes od-flip-bottom{to{transform:rotateX(0)}}
@media (prefers-reduced-motion:reduce){.odo-reel{transition:none}.flip-card .flip-flap{animation:none;visibility:hidden}}
JavaScript
Copy
// Odometer reels and split-flap cards. The number is plain text in the HTML, so it reads correctly without
// JavaScript. The script draws the digits as reels ([data-odometer]) or flip cards ([data-flip]), hides that
// drawing from screen readers and keeps the real number in a visually hidden copy. The digits roll when the
// counter scrolls into view; under prefers-reduced-motion they are drawn at their final values straight away.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const make = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const onView = (el, start) => {
if (reduce || !('IntersectionObserver' in window)) return start(true);
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); start(false); } }, { threshold: 0.6 });
io.observe(el);
};
const mount = (el, face) => {
const copy = make('span', null, el.textContent.trim());
copy.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
face.setAttribute('aria-hidden', 'true');
el.replaceChildren(copy, face);
};
document.querySelectorAll('[data-odometer]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'odo-face'), reels = [];
let left = [...text].filter(c => c >= '0' && c <= '9').length;
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'odo-sep', ch));
left--;
const reel = make('span', 'odo-reel', '0123456789'.repeat(3).split('').join('\n'));
reel.style.setProperty('--k', reels.length);
reels.push([reel, +ch + 10 * (left < 2 ? 2 : left < 4 ? 1 : 0)]);
const win = make('span', 'odo-digit');
win.append(reel);
face.append(win);
});
mount(el, face);
onView(el, () => reels.forEach(([reel, i]) => reel.style.setProperty('--i', i)));
});
document.querySelectorAll('[data-flip]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'flip-face'), cards = [];
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'flip-sep', ch));
const card = make('span', 'flip-card');
for (const part of ['flip-half is-upper', 'flip-half is-lower', 'flip-flap is-upper', 'flip-flap is-lower']) {
const half = make('span', part);
half.append(make('b'));
card.append(half);
}
cards.push([card, +ch]);
face.append(card);
});
const show = (card, d) => card.querySelectorAll('b').forEach(b => { b.textContent = d; });
const flip = (card, from, to) => new Promise(done => {
const [up, low, top, bottom] = card.querySelectorAll('b');
up.textContent = to; low.textContent = from; top.textContent = from; bottom.textContent = to;
card.classList.remove('is-flipping'); void card.offsetWidth; card.classList.add('is-flipping');
setTimeout(() => { low.textContent = to; card.classList.remove('is-flipping'); done(); }, 330);
});
mount(el, face);
cards.forEach(([card, d], k) => show(card, reduce ? d : (d + 10 - 3 - k) % 10));
onView(el, instant => cards.forEach(async ([card, d], k) => {
if (instant) return show(card, d);
for (let s = 3 + k; s > 0; s--) await flip(card, (d + 10 - s) % 10, (d + 11 - s) % 10);
}));
});
})();
Tailwind 4 code
Edit on CodePen ↗
StackBlitz ↗
Requires: Tailwind CSS 4
HTML
Copy
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-od-muted uppercase">Rolling odometer</span>
<section class="od od-roll rounded-[22px] border border-[#e4e0f2] bg-white p-[clamp(24px,4vw,40px)] text-center shadow-[0_14px_34px_-20px_rgba(49,30,110,.35)]" aria-labelledby="od-roll-title">
<h3 class="text-[.8rem] font-bold tracking-[.12em] text-od-accent uppercase" id="od-roll-title">Pull requests reviewed by Patchwork</h3>
<p class="mt-3.5 mb-1.5"><span class="text-[clamp(2.8rem,9vw,5rem)] leading-[1.15] font-extrabold tracking-[-.02em]" data-odometer>1,284,302</span></p>
<p class="text-[.92rem] text-od-muted">since the first review in March 2021, across 9,400 repositories</p>
</section></div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-od-muted uppercase">Mechanical odometer</span>
<section class="od flex flex-wrap items-center justify-between gap-x-8 gap-y-5 rounded-[22px] border border-[#e4e0f2] bg-white p-[clamp(24px,4vw,40px)] shadow-[0_14px_34px_-20px_rgba(49,30,110,.35)]" aria-labelledby="od-mech-title">
<div><p class="text-[.8rem] font-bold tracking-[.12em] text-od-accent uppercase">Vela e-bus fleet</p><h3 class="mt-1 text-[1.3rem] font-bold tracking-[-.01em]" id="od-mech-title">Kilometres driven since 2019</h3>
<p class="text-[.92rem] text-od-muted">212 electric buses on 31 routes, charged overnight at four depots.</p></div>
<p class="od-housing inline-flex items-center gap-2.5 rounded-[14px] bg-[#14121c] px-3.5 py-2.5 text-[#f5f3ff] shadow-[inset_0_2px_6px_rgba(0,0,0,.6),0_1px_0_#fff]"><span class="font-mono text-[clamp(1.5rem,5vw,2.2rem)] leading-[1.15] font-bold" data-odometer>3,048,915</span><span class="text-[.82rem] font-bold tracking-[.08em] text-[#c9c4e0] uppercase">km</span></p>
</section></div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-od-muted uppercase">Split-flap counter</span>
<section class="od rounded-[22px] border border-[#e4e0f2] bg-white p-[clamp(24px,4vw,40px)] text-center shadow-[0_14px_34px_-20px_rgba(49,30,110,.35)]" aria-labelledby="od-flap-title">
<h3 class="text-[.8rem] font-bold tracking-[.12em] text-od-accent uppercase" id="od-flap-title">Tern Maps downloads in October</h3>
<p class="mt-4 mb-2"><span class="text-[clamp(2.2rem,8vw,3.6rem)] font-extrabold" data-flip>48,230</span></p>
<p class="text-[.92rem] text-od-muted">iOS and Android combined, counted at midnight UTC</p>
</section></div>
CSS
Copy
/* Odometer and flip counters. Panels are utilities in the markup; the rules below style the reels (.odo-*) and
split-flap cards (.flip-*) that the script builds at runtime, so they cannot be written as utilities. */
@theme { --color-od-accent: #6d28d9; --color-od-muted: #59546e; }
.od { --od-h: 1.15em; }
[data-odometer], [data-flip] { display: inline-block; font-variant-numeric: tabular-nums; white-space: nowrap; }
.odo-face { display: inline-flex; line-height: var(--od-h); }
.odo-digit { display: inline-block; width: 1ch; height: var(--od-h); overflow: hidden; text-align: center; }
.odo-reel { display: block; white-space: pre; transform: translateY(calc(var(--i, 0) * var(--od-h) * -1));
transition: transform 2.4s cubic-bezier(.16,1,.3,1) calc(var(--k, 0) * 70ms); }
.od-roll .odo-digit { mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent); }
.od-roll .odo-sep { color: var(--color-od-accent); }
.od-housing .odo-face { gap: 3px; }
.od-housing .odo-digit { width: 1.3ch; border-radius: 4px; background: linear-gradient(#07060b, #2d2a3a 48%, #2d2a3a 52%, #07060b); box-shadow: inset 0 0 0 1px rgb(255 255 255 / .06); }
.od-housing .odo-digit:last-child { background: linear-gradient(#cfcbe0, #fff 48%, #fff 52%, #cfcbe0); color: #14121c; }
.od-housing .odo-sep { width: .4ch; color: #a8a3c0; text-align: center; }
.flip-face { display: inline-flex; align-items: flex-end; gap: .12em; }
.flip-card { --fh: 1.45em; position: relative; display: inline-block; width: 1.02em; height: var(--fh); border-radius: .14em; color: #faf8ff;
background: #1d1a29; perspective: 5em; box-shadow: 0 .1em .24em rgb(20 10 50 / .35); }
.flip-card::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; background: #0d0b14; z-index: 3; }
.flip-half, .flip-flap { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; backface-visibility: hidden; }
.flip-half b, .flip-flap b { display: block; height: var(--fh); line-height: var(--fh); text-align: center; font-weight: inherit; }
.flip-half.is-upper, .flip-flap.is-upper { top: 0; border-radius: .14em .14em 0 0; background: #28243a; }
.flip-half.is-lower, .flip-flap.is-lower { bottom: 0; border-radius: 0 0 .14em .14em; background: #1d1a29; }
.flip-half.is-lower b, .flip-flap.is-lower b { transform: translateY(-50%); }
.flip-flap { visibility: hidden; z-index: 2; }
.flip-flap.is-upper { transform-origin: 50% 100%; }
.flip-flap.is-lower { transform-origin: 50% 0; transform: rotateX(90deg); }
.flip-card.is-flipping .flip-flap { visibility: visible; }
.flip-card.is-flipping .flip-flap.is-upper { animation: od-flip-top .16s ease-in forwards; }
.flip-card.is-flipping .flip-flap.is-lower { animation: od-flip-bottom .16s .16s ease-out forwards; }
.flip-sep { padding: 0 .04em; color: var(--color-od-accent); line-height: 1.2; }
@keyframes od-flip-top { to { transform: rotateX(-90deg); } }
@keyframes od-flip-bottom { to { transform: rotateX(0); } }
@media (prefers-reduced-motion: reduce) { .odo-reel { transition: none; } .flip-card .flip-flap { animation: none; visibility: hidden; } }
JavaScript
Copy
// Odometer reels and split-flap cards. The number is plain text in the HTML, so it reads correctly without
// JavaScript. The script draws the digits as reels ([data-odometer]) or flip cards ([data-flip]), hides that
// drawing from screen readers and keeps the real number in a visually hidden copy. The digits roll when the
// counter scrolls into view; under prefers-reduced-motion they are drawn at their final values straight away.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const make = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const onView = (el, start) => {
if (reduce || !('IntersectionObserver' in window)) return start(true);
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); start(false); } }, { threshold: 0.6 });
io.observe(el);
};
const mount = (el, face) => {
const copy = make('span', null, el.textContent.trim());
copy.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
face.setAttribute('aria-hidden', 'true');
el.replaceChildren(copy, face);
};
document.querySelectorAll('[data-odometer]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'odo-face'), reels = [];
let left = [...text].filter(c => c >= '0' && c <= '9').length;
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'odo-sep', ch));
left--;
const reel = make('span', 'odo-reel', '0123456789'.repeat(3).split('').join('\n'));
reel.style.setProperty('--k', reels.length);
reels.push([reel, +ch + 10 * (left < 2 ? 2 : left < 4 ? 1 : 0)]);
const win = make('span', 'odo-digit');
win.append(reel);
face.append(win);
});
mount(el, face);
onView(el, () => reels.forEach(([reel, i]) => reel.style.setProperty('--i', i)));
});
document.querySelectorAll('[data-flip]').forEach(el => {
const text = el.textContent.trim(), face = make('span', 'flip-face'), cards = [];
[...text].forEach(ch => {
if (ch < '0' || ch > '9') return face.append(make('span', 'flip-sep', ch));
const card = make('span', 'flip-card');
for (const part of ['flip-half is-upper', 'flip-half is-lower', 'flip-flap is-upper', 'flip-flap is-lower']) {
const half = make('span', part);
half.append(make('b'));
card.append(half);
}
cards.push([card, +ch]);
face.append(card);
});
const show = (card, d) => card.querySelectorAll('b').forEach(b => { b.textContent = d; });
const flip = (card, from, to) => new Promise(done => {
const [up, low, top, bottom] = card.querySelectorAll('b');
up.textContent = to; low.textContent = from; top.textContent = from; bottom.textContent = to;
card.classList.remove('is-flipping'); void card.offsetWidth; card.classList.add('is-flipping');
setTimeout(() => { low.textContent = to; card.classList.remove('is-flipping'); done(); }, 330);
});
mount(el, face);
cards.forEach(([card, d], k) => show(card, reduce ? d : (d + 10 - 3 - k) % 10));
onView(el, instant => cards.forEach(async ([card, d], k) => {
if (instant) return show(card, d);
for (let s = 3 + k; s > 0; s--) await flip(card, (d + 10 - s) % 10, (d + 11 - s) % 10);
}));
});
})();