25 CSS Number Counter Animations12 / 25

Light JSMIT licensed

Retro Flip Odometer & Timer

The split-flap board, done properly: each digit is a card split across its horizontal midline, and a change flips the top half down over a pre-rendered next value. The hinge shadow, the card gap and the slight overshoot at the end of the flip are what separate a convincing mechanical display from a rotating rectangle.

Published Updated

Live Demo
Try it

The code

<section class="cnc-12" aria-label="Retro flip odometer and timer demo">
  <div class="cnc-12__stage">
    <p class="cnc-12__title">Session uptime</p>
    <div class="cnc-12__board" aria-hidden="true">
      <div class="cnc-12__group" data-unit="h"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">hrs</small></div>
      <span class="cnc-12__colon">:</span>
      <div class="cnc-12__group" data-unit="m"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">min</small></div>
      <span class="cnc-12__colon">:</span>
      <div class="cnc-12__group" data-unit="s"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">sec</small></div>
    </div>
    <time class="cnc-12__sr" datetime="PT0S">0 hours 0 minutes 0 seconds elapsed</time>
    <div class="cnc-12__ctrls">
      <button class="cnc-12__btn" type="button" data-act="toggle" aria-pressed="true">Pause</button>
      <button class="cnc-12__btn" type="button" data-act="reset">Reset</button>
    </div>
    <p class="cnc-12__chip" aria-hidden="true">two-leaf 3D hinge · sequenced fold/unfold keyframes · backface-visibility</p>
  </div>
</section>
.cnc-12,
.cnc-12 *,
.cnc-12 *::before,
.cnc-12 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-12 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-12-bg: oklch(0.15 0.008 260);
  --cnc-12-card: oklch(0.26 0.01 260);
  --cnc-12-card2: oklch(0.19 0.01 260);
  --cnc-12-ink: oklch(0.97 0.005 90);
  --cnc-12-mut: oklch(0.62 0.015 260);
  --cnc-12-acc: oklch(0.82 0.15 80);
  background: var(--cnc-12-bg);
  color: var(--cnc-12-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-12__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(18px,4vw,48px);
  background: radial-gradient(65% 50% at 50% 35%,oklch(0.24 0.02 260/.8),transparent 72%);
}

.cnc-12__title {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--cnc-12-mut);
}

.cnc-12__board {
  display: flex;
  align-items: flex-start;
  gap: clamp(6px,1.6vw,14px);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-12__group {
  display: flex;
  gap: 5px;
  position: relative;
  padding-bottom: 22px;
}

.cnc-12__cap {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--cnc-12-mut);
}

.cnc-12__colon {
  align-self: center;
  margin-bottom: 22px;
  font-size: clamp(26px,5vw,40px);
  font-weight: 700;
  color: var(--cnc-12-acc);
  animation: cnc-12-blink 2s steps(1,end) infinite;
}

@keyframes cnc-12-blink {
  0%,
    50% {
    opacity: 1;
  }

  51%,
    100% {
    opacity: .28;
  }
}

.cnc-12__card {
  position: relative;
  display: block;
  width: clamp(44px,9vw,68px);
  height: clamp(72px,15vw,108px);
  perspective: 320px;
  font-size: clamp(38px,7.6vw,58px);
  font-weight: 700;
  line-height: 1;
}

.cnc-12__half {
  position: absolute;
  left: 0;
  right: 0;
  height: 50%;
  overflow: hidden;
  backface-visibility: hidden;
  background: var(--cnc-12-card);
  box-shadow: inset 0 0 0 1px oklch(0 0 0/.5);
}

.cnc-12__half span {
  position: absolute;
  left: 0;
  right: 0;
  height: 100%;
  display: grid;
  place-items: center;
  text-shadow: 0 2px 4px oklch(0 0 0/.6);
  line-height: 1;
  -webkit-font-smoothing: antialiased;
}

.cnc-12__half--t {
  top: 0;
  border-radius: 8px 8px 0 0;
  background: linear-gradient(180deg,var(--cnc-12-card),color-mix(in oklch,var(--cnc-12-card) 82%,black));
}

.cnc-12__half--t span {
  top: 0;
  height: 200%;
  place-items: center;
}

.cnc-12__half--b {
  bottom: 0;
  border-radius: 0 0 8px 8px;
  background: linear-gradient(180deg,var(--cnc-12-card2),color-mix(in oklch,var(--cnc-12-card2) 88%,white 6%));
}

.cnc-12__half--b span {
  bottom: 0;
  height: 200%;
  place-items: center;
}

.cnc-12__leaf {
  z-index: 2;
}

.cnc-12__leaf--t {
  transform-origin: bottom center;
  transform: rotateX(0);
}

.cnc-12__leaf--b {
  transform-origin: top center;
  transform: rotateX(90deg);
}

.cnc-12__card::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% - 1px);
  height: 2px;
  background: oklch(0 0 0/.75);
  z-index: 5;
  border-radius: 1px;
}

.cnc-12__card.is-flip .cnc-12__leaf--t {
  animation: cnc-12-fold .3s cubic-bezier(.4,0,.9,.5) forwards;
}

.cnc-12__card.is-flip .cnc-12__leaf--b {
  animation: cnc-12-unfold .3s .3s cubic-bezier(.15,.7,.4,1.1) forwards;
}

@keyframes cnc-12-fold {
  to {
    transform: rotateX(-90deg);
    filter: brightness(.55);
  }
}

@keyframes cnc-12-unfold {
  from {
    transform: rotateX(90deg);
    filter: brightness(.6);
  }

  to {
    transform: rotateX(0);
    filter: brightness(1);
  }
}

.cnc-12__ctrls {
  display: flex;
  gap: 10px;
}

.cnc-12__btn {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid oklch(1 0 0/.16);
  border-radius: 99px;
  background: oklch(1 0 0/.06);
  color: var(--cnc-12-ink);
  font: inherit;
  font-size: 13.5px;
  font-weight: 620;
  cursor: pointer;
  transition: background .2s,transform .2s;
}

.cnc-12__btn:hover {
  background: oklch(1 0 0/.13);
  transform: translateY(-1px);
}

.cnc-12__btn:focus-visible {
  outline: 3px solid var(--cnc-12-acc);
  outline-offset: 3px;
}

.cnc-12__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-12__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-12-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,480px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-12__card.is-flip .cnc-12__leaf--t,
    .cnc-12__card.is-flip .cnc-12__leaf--b {
    animation-duration: 1ms;
  }

  .cnc-12__colon {
    animation: none;
  }
}
(() => {
  document.querySelectorAll('.cnc-12').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const cards = [...root.querySelectorAll('.cnc-12__card')];
    const srEl = root.querySelector('.cnc-12__sr');
    const toggle = root.querySelector('[data-act="toggle"]');

    const half = (kind, leaf, d) => {
      const el = document.createElement('span');
      el.className = 'cnc-12__half cnc-12__half--' + kind + (leaf ? ' cnc-12__leaf cnc-12__leaf--' + kind : '');
      el.innerHTML = '<span>' + d + '</span>';
      return el;
    };
    const paint = (card, d) => {                    // static faces show the current digit
      card.innerHTML = '';
      card.append(half('t', false, d), half('b', false, d));
      card.dataset.d = d;
    };
    const flipTo = (card, d) => {
      if (card.dataset.d === String(d)) return;     // unchanged digits never animate
      const cur = card.dataset.d;
      card.innerHTML = '';
      card.append(half('t', false, d), half('b', false, cur), half('t', true, cur), half('b', true, d));
      card.classList.remove('is-flip'); void card.offsetWidth; card.classList.add('is-flip');
      card.dataset.d = d;
      setTimeout(() => { if (card.dataset.d === String(d)) paint(card, d); }, 640);
    };

    let secs = 0, running = true;
    const render = () => {
      const h = Math.floor(secs / 3600) % 100, m = Math.floor(secs / 60) % 60, s = secs % 60;
      const digits = String(h).padStart(2, '0') + String(m).padStart(2, '0') + String(s).padStart(2, '0');
      [...digits].forEach((d, i) => flipTo(cards[i], d));
      srEl.setAttribute('datetime', 'PT' + h + 'H' + m + 'M' + s + 'S');
      srEl.textContent = h + ' hours ' + m + ' minutes ' + s + ' seconds elapsed';
    };
    cards.forEach((c) => paint(c, '0'));
    secs = 3 * 3600 + 41 * 60 + 57; render();
    const id = setInterval(() => { if (running && document.visibilityState === 'visible') { secs++; render(); } }, 1000);
    toggle.addEventListener('click', () => {
      running = !running;
      toggle.textContent = running ? 'Pause' : 'Resume';
      toggle.setAttribute('aria-pressed', String(running));
    });
    root.querySelector('[data-act="reset"]').addEventListener('click', () => { secs = 0; render(); });
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Number Counter Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Retro Flip Odometer & Timer
Source: https://codefronts.com/motion/css-number-counter-animations/podcast-flip-counter/

The split-flap board, done properly: each digit is a card split across its horizontal midline, and a change flips the top half down over a pre-rendered next value. The hinge shadow, the card gap and the slight overshoot at the end of the flip are what separate a convincing mechanical display from a rotating rectangle.
## HTML
```html
<section class="cnc-12" aria-label="Retro flip odometer and timer demo">
  <div class="cnc-12__stage">
    <p class="cnc-12__title">Session uptime</p>
    <div class="cnc-12__board" aria-hidden="true">
      <div class="cnc-12__group" data-unit="h"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">hrs</small></div>
      <span class="cnc-12__colon">:</span>
      <div class="cnc-12__group" data-unit="m"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">min</small></div>
      <span class="cnc-12__colon">:</span>
      <div class="cnc-12__group" data-unit="s"><span class="cnc-12__card" data-d="0"></span><span class="cnc-12__card" data-d="0"></span><small class="cnc-12__cap">sec</small></div>
    </div>
    <time class="cnc-12__sr" datetime="PT0S">0 hours 0 minutes 0 seconds elapsed</time>
    <div class="cnc-12__ctrls">
      <button class="cnc-12__btn" type="button" data-act="toggle" aria-pressed="true">Pause</button>
      <button class="cnc-12__btn" type="button" data-act="reset">Reset</button>
    </div>
    <p class="cnc-12__chip" aria-hidden="true">two-leaf 3D hinge · sequenced fold/unfold keyframes · backface-visibility</p>
  </div>
</section>
```
## CSS
```css
.cnc-12,
.cnc-12 *,
.cnc-12 *::before,
.cnc-12 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-12 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-12-bg: oklch(0.15 0.008 260);
  --cnc-12-card: oklch(0.26 0.01 260);
  --cnc-12-card2: oklch(0.19 0.01 260);
  --cnc-12-ink: oklch(0.97 0.005 90);
  --cnc-12-mut: oklch(0.62 0.015 260);
  --cnc-12-acc: oklch(0.82 0.15 80);
  background: var(--cnc-12-bg);
  color: var(--cnc-12-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-12__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(18px,4vw,48px);
  background: radial-gradient(65% 50% at 50% 35%,oklch(0.24 0.02 260/.8),transparent 72%);
}

.cnc-12__title {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--cnc-12-mut);
}

.cnc-12__board {
  display: flex;
  align-items: flex-start;
  gap: clamp(6px,1.6vw,14px);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-12__group {
  display: flex;
  gap: 5px;
  position: relative;
  padding-bottom: 22px;
}

.cnc-12__cap {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--cnc-12-mut);
}

.cnc-12__colon {
  align-self: center;
  margin-bottom: 22px;
  font-size: clamp(26px,5vw,40px);
  font-weight: 700;
  color: var(--cnc-12-acc);
  animation: cnc-12-blink 2s steps(1,end) infinite;
}

@keyframes cnc-12-blink {
  0%,
    50% {
    opacity: 1;
  }

  51%,
    100% {
    opacity: .28;
  }
}

.cnc-12__card {
  position: relative;
  display: block;
  width: clamp(44px,9vw,68px);
  height: clamp(72px,15vw,108px);
  perspective: 320px;
  font-size: clamp(38px,7.6vw,58px);
  font-weight: 700;
  line-height: 1;
}

.cnc-12__half {
  position: absolute;
  left: 0;
  right: 0;
  height: 50%;
  overflow: hidden;
  backface-visibility: hidden;
  background: var(--cnc-12-card);
  box-shadow: inset 0 0 0 1px oklch(0 0 0/.5);
}

.cnc-12__half span {
  position: absolute;
  left: 0;
  right: 0;
  height: 100%;
  display: grid;
  place-items: center;
  text-shadow: 0 2px 4px oklch(0 0 0/.6);
  line-height: 1;
  -webkit-font-smoothing: antialiased;
}

.cnc-12__half--t {
  top: 0;
  border-radius: 8px 8px 0 0;
  background: linear-gradient(180deg,var(--cnc-12-card),color-mix(in oklch,var(--cnc-12-card) 82%,black));
}

.cnc-12__half--t span {
  top: 0;
  height: 200%;
  place-items: center;
}

.cnc-12__half--b {
  bottom: 0;
  border-radius: 0 0 8px 8px;
  background: linear-gradient(180deg,var(--cnc-12-card2),color-mix(in oklch,var(--cnc-12-card2) 88%,white 6%));
}

.cnc-12__half--b span {
  bottom: 0;
  height: 200%;
  place-items: center;
}

.cnc-12__leaf {
  z-index: 2;
}

.cnc-12__leaf--t {
  transform-origin: bottom center;
  transform: rotateX(0);
}

.cnc-12__leaf--b {
  transform-origin: top center;
  transform: rotateX(90deg);
}

.cnc-12__card::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% - 1px);
  height: 2px;
  background: oklch(0 0 0/.75);
  z-index: 5;
  border-radius: 1px;
}

.cnc-12__card.is-flip .cnc-12__leaf--t {
  animation: cnc-12-fold .3s cubic-bezier(.4,0,.9,.5) forwards;
}

.cnc-12__card.is-flip .cnc-12__leaf--b {
  animation: cnc-12-unfold .3s .3s cubic-bezier(.15,.7,.4,1.1) forwards;
}

@keyframes cnc-12-fold {
  to {
    transform: rotateX(-90deg);
    filter: brightness(.55);
  }
}

@keyframes cnc-12-unfold {
  from {
    transform: rotateX(90deg);
    filter: brightness(.6);
  }

  to {
    transform: rotateX(0);
    filter: brightness(1);
  }
}

.cnc-12__ctrls {
  display: flex;
  gap: 10px;
}

.cnc-12__btn {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid oklch(1 0 0/.16);
  border-radius: 99px;
  background: oklch(1 0 0/.06);
  color: var(--cnc-12-ink);
  font: inherit;
  font-size: 13.5px;
  font-weight: 620;
  cursor: pointer;
  transition: background .2s,transform .2s;
}

.cnc-12__btn:hover {
  background: oklch(1 0 0/.13);
  transform: translateY(-1px);
}

.cnc-12__btn:focus-visible {
  outline: 3px solid var(--cnc-12-acc);
  outline-offset: 3px;
}

.cnc-12__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cnc-12__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-12-mut);
  padding: 7px 14px;
  border: 1px solid oklch(1 0 0/.13);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,480px);
}

@media (prefers-reduced-motion: reduce) {
  .cnc-12__card.is-flip .cnc-12__leaf--t,
    .cnc-12__card.is-flip .cnc-12__leaf--b {
    animation-duration: 1ms;
  }

  .cnc-12__colon {
    animation: none;
  }
}
```

## JavaScript
```js
(() => {
  document.querySelectorAll('.cnc-12').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const cards = [...root.querySelectorAll('.cnc-12__card')];
    const srEl = root.querySelector('.cnc-12__sr');
    const toggle = root.querySelector('[data-act="toggle"]');

    const half = (kind, leaf, d) => {
      const el = document.createElement('span');
      el.className = 'cnc-12__half cnc-12__half--' + kind + (leaf ? ' cnc-12__leaf cnc-12__leaf--' + kind : '');
      el.innerHTML = '<span>' + d + '</span>';
      return el;
    };
    const paint = (card, d) => {                    // static faces show the current digit
      card.innerHTML = '';
      card.append(half('t', false, d), half('b', false, d));
      card.dataset.d = d;
    };
    const flipTo = (card, d) => {
      if (card.dataset.d === String(d)) return;     // unchanged digits never animate
      const cur = card.dataset.d;
      card.innerHTML = '';
      card.append(half('t', false, d), half('b', false, cur), half('t', true, cur), half('b', true, d));
      card.classList.remove('is-flip'); void card.offsetWidth; card.classList.add('is-flip');
      card.dataset.d = d;
      setTimeout(() => { if (card.dataset.d === String(d)) paint(card, d); }, 640);
    };

    let secs = 0, running = true;
    const render = () => {
      const h = Math.floor(secs / 3600) % 100, m = Math.floor(secs / 60) % 60, s = secs % 60;
      const digits = String(h).padStart(2, '0') + String(m).padStart(2, '0') + String(s).padStart(2, '0');
      [...digits].forEach((d, i) => flipTo(cards[i], d));
      srEl.setAttribute('datetime', 'PT' + h + 'H' + m + 'M' + s + 'S');
      srEl.textContent = h + ' hours ' + m + ' minutes ' + s + ' seconds elapsed';
    };
    cards.forEach((c) => paint(c, '0'));
    secs = 3 * 3600 + 41 * 60 + 57; render();
    const id = setInterval(() => { if (running && document.visibilityState === 'visible') { secs++; render(); } }, 1000);
    toggle.addEventListener('click', () => {
      running = !running;
      toggle.textContent = running ? 'Pause' : 'Resume';
      toggle.setAttribute('aria-pressed', String(running));
    });
    root.querySelector('[data-act="reset"]').addEventListener('click', () => { secs = 0; render(); });
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
```

How this works

A flip card is four layers stacked in one 3D context — static top, static bottom, and two flipping leaves:

.card { perspective: 260px; }
.leaf.top    { transform-origin: bottom; }   /* current, flips down */
.leaf.bottom { transform-origin: top; rotate: x -90deg; } /* next, flips up */

.card.flip .leaf.top    { animation: fold .32s ease-in forwards }
.card.flip .leaf.bottom { animation: unfold .32s .32s ease-out forwards }
@keyframes fold   { to { rotate: x -90deg } }
@keyframes unfold { to { rotate: x 0deg   } }

The two halves are sequenced, not simultaneous: the top leaf falls to 90° first, then the bottom leaf continues from -90° to flat. That handover at the midpoint is the mechanism — running them in parallel makes the card look like it is spinning through itself.

backface-visibility: hidden stops the reverse face flashing mid-rotation, and a gradient overlay on the falling leaf darkens as it rotates, faking the light falloff of a physical flap. JS only ever writes the next digit and toggles a class; the entire animation, timing and easing live in CSS, which is what makes the board cheap enough to run six digits at once.

Make it yours

  • Alphanumeric board: swap digits for letters and you have an airport departure display — the flip logic is character-agnostic.
  • Speed and feel: shorten to .18s for a nervous ticker, lengthen to .5s with a slight cubic-bezier overshoot for a heavy, mechanical board.
  • Sound: a short click sample on animationstart (behind a user-enabled toggle) massively increases the perceived realism.
  • Colour: swap the card gradient for warm cream + brown to get a 1970s station board instead of the modern black.

Gotchas — read before shipping

  • Without backface-visibility:hidden you see mirrored digits through the flipping leaf on Safari and Firefox.
  • The hinge line must be a real 1px gap or dark border between halves — without it the two leaves read as one block and the flip looks like a spin.
  • Sequencing matters: the bottom leaf must start only after the top reaches 90°. Use animation-delay equal to the first duration, not a JS timeout.
  • Six independently flipping cards on a 1s tick is fine; sixty is not. Batch updates and skip digits that did not change.
  • The board is decorative motion — mark it aria-hidden and expose the value in a <time datetime> or visually hidden span.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

3D transforms, perspective and backface-visibility are universal (IE10+ with prefixes). The rotate shorthand needs Chrome 104 / Safari 14.1 / Firefox 72 — swap to transform:rotateX() for wider reach.

Search CodeFronts

Loading…