Open demo

CSS code

HTML
<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
/* 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
// 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

Requires: Bootstrap 5.3.8 CSS

HTML
<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
/* 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
// 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

Requires: Tailwind CSS 4

HTML
<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
/* 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
// 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);
    }));
  });
})();

About this set

Counters with mechanical motion, for a total that deserves a moment on the page. Rolling odometer turns each digit into a vertical reel of 0 to 9 that rolls into place, with the reels fading at the top and bottom edge. Mechanical odometer draws the digits as dark wheels in a housing with the last wheel inverted, like a vehicle odometer, beside a short text block. Split-flap counter shows each digit on a dark card with a centre seam, flipping a few times before it lands, like a departure board. The number is plain text in the HTML. A short vanilla script reads it, builds the reels or cards, and starts them when an IntersectionObserver sees the counter. Reels move with one CSS transform transition per digit, staggered from left to right, with the right-hand digits travelling further so they spin more. Flip cards use two half-card flaps that rotate on the X axis in 160 ms steps. Separators stay as text, so 1,284,302 keeps its commas.

What’s included

  • Digit reels built from the plain number in the HTML
  • Three looks: rolling digits, mechanical wheels, split-flap cards
  • Starts on view with IntersectionObserver
  • Reel height and card size scale with font-size
  • Built at the final digits with no motion under prefers-reduced-motion

Accessibility

The reels and cards are aria-hidden and a visually hidden copy of the number is what screen readers read, so they hear 1,284,302 once rather than thirty digits. Without JavaScript the plain number shows. With prefers-reduced-motion the reels and cards are drawn at their final digits, the transition and flip animation are switched off, and nothing counts up from 0.

Customise it

Change --od-accent for the separators and eyebrow text and --od-h for the reel height, and the font-size on [data-odometer] or [data-flip] to scale everything. The wheel and card colours are the gradients in .od-housing .odo-digit and .flip-card; in Tailwind they are in the style block because the script creates those elements.