25 CSS Number Counter Animations08 / 25

Pure CSSMIT licensed

Pure CSS Odometer Rolling Digit Counter

A mechanical odometer with no JavaScript at all: six digit columns, each a 0–9 strip translated by its own keyframe animation, with per-column durations in a 10:1 cascade so the units wheel spins ten times faster than the tens — the actual gearing of a physical counter. The blur streak on fast columns and the metal gradient sell the illusion.

Published

Live Demo
Try it

The code

<section class="cnc-08" aria-label="Pure CSS odometer rolling digit counter demo">
  <div class="cnc-08__stage">
    <p class="cnc-08__title">Total distance logged</p>
    <div class="cnc-08__meter" aria-hidden="true">
      <span class="cnc-08__col" style="--cnc-08-dur:640s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:64s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:6.4s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__sep">,</span>
      <span class="cnc-08__col" style="--cnc-08-dur:2.4s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:.9s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col cnc-08__col--fast" style="--cnc-08-dur:.32s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__unit">km</span>
    </div>
    <p class="cnc-08__sr">Approximately 138,402 kilometres logged</p>
    <p class="cnc-08__chip" aria-hidden="true">10 : 1 gearing per place value · steps(10,end) · translateY(-100%) loop · zero JS</p>
  </div>
</section>
.cnc-08,
.cnc-08 *,
.cnc-08 *::before,
.cnc-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-08-bg: oklch(0.14 0.012 60);
  --cnc-08-face: oklch(0.24 0.015 60);
  --cnc-08-ink: oklch(0.96 0.01 85);
  --cnc-08-mut: oklch(0.64 0.02 70);
  --cnc-08-acc: oklch(0.82 0.15 75);
  background: var(--cnc-08-bg);
  color: var(--cnc-08-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-08__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 18px;
  padding: clamp(18px,4vw,48px);
  background: radial-gradient(60% 50% at 50% 40%,oklch(0.28 0.03 70/.6),transparent 72%);
}

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

.cnc-08__meter {
  display: flex;
  align-items: center;
  gap: 3px;
  padding: 16px 18px;
  border-radius: 14px;
  background: linear-gradient(180deg,oklch(0.3 0.015 60),oklch(0.18 0.012 60));
  box-shadow: inset 0 2px 0 oklch(1 0 0/.12),inset 0 -2px 6px oklch(0 0 0/.6),0 22px 50px oklch(0 0 0/.55);
  font-size: clamp(38px,8vw,64px);
  font-weight: 700;
  letter-spacing: .02em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-08__col {
  position: relative;
  display: block;
  height: 1em;
  width: .66em;
  overflow: hidden;
  border-radius: 4px;
  background: linear-gradient(180deg,oklch(0.1 0.01 60),oklch(0.2 0.012 60) 45%,oklch(0.08 0.01 60));
  box-shadow: inset 0 0 0 1px oklch(0 0 0/.6);
}

.cnc-08__col::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg,oklch(0 0 0/.75),transparent 26%,transparent 74%,oklch(0 0 0/.75));
}

.cnc-08__strip {
  display: block;
  animation: cnc-08-roll var(--cnc-08-dur,1s) steps(10,end) infinite;
  will-change: transform;
}

.cnc-08__col--fast .cnc-08__strip {
  animation-timing-function: linear;
  filter: blur(.4px);
}

.cnc-08__strip i {
  display: block;
  height: 1em;
  line-height: 1;
  font-style: normal;
  text-align: center;
  color: var(--cnc-08-ink);
  text-shadow: 0 1px 0 oklch(0 0 0/.8);
}

@keyframes cnc-08-roll {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(-100%);
  }
}

.cnc-08__sep {
  color: var(--cnc-08-acc);
  padding: 0 2px;
  transform: translateY(.12em);
}

.cnc-08__unit {
  margin-left: 10px;
  font-size: .32em;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--cnc-08-acc);
}

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

.cnc-08__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-08-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-08__strip {
    animation: none;
  }

  .cnc-08__col--fast .cnc-08__strip {
    filter: none;
  }
}
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: Pure CSS Odometer Rolling Digit Counter
Source: https://codefronts.com/motion/css-number-counter-animations/pure-css-odometer-rolling-digit-counter/

A mechanical odometer with no JavaScript at all: six digit columns, each a 0–9 strip translated by its own keyframe animation, with per-column durations in a 10:1 cascade so the units wheel spins ten times faster than the tens — the actual gearing of a physical counter. The blur streak on fast columns and the metal gradient sell the illusion.
## HTML
```html
<section class="cnc-08" aria-label="Pure CSS odometer rolling digit counter demo">
  <div class="cnc-08__stage">
    <p class="cnc-08__title">Total distance logged</p>
    <div class="cnc-08__meter" aria-hidden="true">
      <span class="cnc-08__col" style="--cnc-08-dur:640s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:64s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:6.4s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__sep">,</span>
      <span class="cnc-08__col" style="--cnc-08-dur:2.4s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col" style="--cnc-08-dur:.9s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__col cnc-08__col--fast" style="--cnc-08-dur:.32s"><span class="cnc-08__strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>0</i></span></span>
      <span class="cnc-08__unit">km</span>
    </div>
    <p class="cnc-08__sr">Approximately 138,402 kilometres logged</p>
    <p class="cnc-08__chip" aria-hidden="true">10 : 1 gearing per place value · steps(10,end) · translateY(-100%) loop · zero JS</p>
  </div>
</section>
```
## CSS
```css
.cnc-08,
.cnc-08 *,
.cnc-08 *::before,
.cnc-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-08-bg: oklch(0.14 0.012 60);
  --cnc-08-face: oklch(0.24 0.015 60);
  --cnc-08-ink: oklch(0.96 0.01 85);
  --cnc-08-mut: oklch(0.64 0.02 70);
  --cnc-08-acc: oklch(0.82 0.15 75);
  background: var(--cnc-08-bg);
  color: var(--cnc-08-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-08__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 18px;
  padding: clamp(18px,4vw,48px);
  background: radial-gradient(60% 50% at 50% 40%,oklch(0.28 0.03 70/.6),transparent 72%);
}

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

.cnc-08__meter {
  display: flex;
  align-items: center;
  gap: 3px;
  padding: 16px 18px;
  border-radius: 14px;
  background: linear-gradient(180deg,oklch(0.3 0.015 60),oklch(0.18 0.012 60));
  box-shadow: inset 0 2px 0 oklch(1 0 0/.12),inset 0 -2px 6px oklch(0 0 0/.6),0 22px 50px oklch(0 0 0/.55);
  font-size: clamp(38px,8vw,64px);
  font-weight: 700;
  letter-spacing: .02em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-08__col {
  position: relative;
  display: block;
  height: 1em;
  width: .66em;
  overflow: hidden;
  border-radius: 4px;
  background: linear-gradient(180deg,oklch(0.1 0.01 60),oklch(0.2 0.012 60) 45%,oklch(0.08 0.01 60));
  box-shadow: inset 0 0 0 1px oklch(0 0 0/.6);
}

.cnc-08__col::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg,oklch(0 0 0/.75),transparent 26%,transparent 74%,oklch(0 0 0/.75));
}

.cnc-08__strip {
  display: block;
  animation: cnc-08-roll var(--cnc-08-dur,1s) steps(10,end) infinite;
  will-change: transform;
}

.cnc-08__col--fast .cnc-08__strip {
  animation-timing-function: linear;
  filter: blur(.4px);
}

.cnc-08__strip i {
  display: block;
  height: 1em;
  line-height: 1;
  font-style: normal;
  text-align: center;
  color: var(--cnc-08-ink);
  text-shadow: 0 1px 0 oklch(0 0 0/.8);
}

@keyframes cnc-08-roll {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(-100%);
  }
}

.cnc-08__sep {
  color: var(--cnc-08-acc);
  padding: 0 2px;
  transform: translateY(.12em);
}

.cnc-08__unit {
  margin-left: 10px;
  font-size: .32em;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--cnc-08-acc);
}

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

.cnc-08__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-08-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-08__strip {
    animation: none;
  }

  .cnc-08__col--fast .cnc-08__strip {
    filter: none;
  }
}
```

How this works

Every column is the same markup — a strip of eleven cells (0-9 plus a repeated 0 for the seamless wrap) inside a one-cell-tall window:

.window { height: 1em; overflow: hidden; }
.strip  { animation: roll var(--cnc-08-dur) steps(10, end) infinite; }
@keyframes roll { to { transform: translateY(-100%); } }

Because the strip is exactly ten cells tall plus the repeat, translating it by -100% lands perfectly on the next 0. steps(10, end) makes it click digit-by-digit like a mechanism; drop the steps() and you get a smooth blur-roll instead — both are provided, toggled by a class on the root.

.col:nth-child(1) { --cnc-08-dur: 1s;   }  /* units  */
.col:nth-child(2) { --cnc-08-dur: 10s;  }  /* tens   */
.col:nth-child(3) { --cnc-08-dur: 100s; }  /* hundreds */

Each place value takes ten times as long as the one to its right — that single rule is what makes the whole thing read as a real counter rather than six independent spinners. The final "settle" version uses animation-fill-mode:forwards with a one-shot keyframe per column so the odometer lands on a specific reading and stops.

Make it yours

  • Land on a fixed number: give each column a one-shot keyframe to { transform: translateY(calc(var(--cnc-08-d) * -10%)) } with forwards, where --cnc-08-d is that column's target digit.
  • Smooth vs. mechanical: swap steps(10,end) for linear to get a continuous roll, or steps(10,jump-none) to hold each digit fractionally longer.
  • Vintage styling: add an inset box-shadow top and bottom on the window for a curved-drum shadow, and a 1px dark divider between columns.
  • Comma groups: insert a static separator element between the third and fourth columns — it is outside the animation, so it never moves.

Gotchas — read before shipping

  • A 0-9 strip is ten cells but the loop needs eleven — repeat the leading 0 at the end, otherwise the wrap jumps a frame.
  • Percentage translateY resolves against the element's own height, so the strip must be exactly N × cell height with no gaps, margins or line-height slack. Set line-height:1 on the cells.
  • steps() with the wrong direction (start vs end) shifts the whole reading by one digit — visually subtle, functionally wrong.
  • This is decorative motion. Give the odometer aria-hidden and put the real number in a visually hidden span, or a screen reader reads a spinning wheel.
  • Infinite spinners keep the compositor awake and drain battery. Stop them with prefers-reduced-motion and consider animation-play-state:paused when off-screen.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

translateY + steps() timing functions work in every browser back to IE10-era syntax. Only the oklch theming is modern; replace with hex for universal support.

Techniques used in this demo

Search CodeFronts

Loading…