25 CSS Number Counter Animations07 / 25

Light JSMIT licensed

Live Visitor / Online User Count Widget

The "32 people are viewing this right now" widget, built the way a real-time product should build it: a per-digit roll so only the digits that changed move, a breathing live dot, a sparkline of the last 30 seconds, and an aria-live region that speaks at most once every ten seconds no matter how often the socket fires.

Published

Live Demo
Try it

The code

<section class="cnc-07" aria-label="Live visitor count widget demo">
  <div class="cnc-07__stage">
    <div class="cnc-07__widget">
      <header class="cnc-07__top">
        <span class="cnc-07__live"><span class="cnc-07__dot" aria-hidden="true"></span>Live now</span>
        <span class="cnc-07__src">codefronts.com</span>
      </header>
      <p class="cnc-07__count" aria-hidden="true"><span class="cnc-07__digits"></span></p>
      <p class="cnc-07__label">people viewing this page</p>
      <svg class="cnc-07__spark" viewBox="0 0 200 44" preserveAspectRatio="none" aria-hidden="true">
        <polyline class="cnc-07__sparkline" points=""/>
      </svg>
      <footer class="cnc-07__foot">
        <span class="cnc-07__stat"><b class="cnc-07__peak">48</b> peak today</span>
        <span class="cnc-07__stat"><b class="cnc-07__avg">27</b> 24h average</span>
      </footer>
      <p class="cnc-07__sr" role="status" aria-live="polite"></p>
    </div>
    <p class="cnc-07__chip" aria-hidden="true">per-digit translateY strips · unchanged digits untouched · 10s throttled live region</p>
  </div>
</section>
.cnc-07,
.cnc-07 *,
.cnc-07 *::before,
.cnc-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-07 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-07-bg: oklch(0.15 0.018 250);
  --cnc-07-card: oklch(0.21 0.022 250);
  --cnc-07-ink: oklch(0.98 0.004 250);
  --cnc-07-mut: oklch(0.66 0.02 250);
  --cnc-07-acc: oklch(0.8 0.16 145);
  background: var(--cnc-07-bg);
  color: var(--cnc-07-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-07__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(18px,4vw,48px);
}

.cnc-07__widget {
  container-type: inline-size;
  width: min(100%,340px);
  padding: 20px;
  border-radius: 20px;
  background: var(--cnc-07-card);
  box-shadow: inset 0 0 0 1px oklch(1 0 0/.08),0 22px 54px oklch(0 0 0/.45);
}

.cnc-07__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 11.5px;
}

.cnc-07__live {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 10px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--cnc-07-acc) 15%,transparent);
  color: var(--cnc-07-acc);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.cnc-07__dot {
  position: relative;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}

.cnc-07__dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: currentColor;
  animation: cnc-07-ping 1.8s cubic-bezier(0,0,.2,1) infinite;
}

@keyframes cnc-07-ping {
  0% {
    opacity: .7;
    scale: 1;
  }

  80%,
    100% {
    opacity: 0;
    scale: 3.4;
  }
}

.cnc-07__src {
  color: var(--cnc-07-mut);
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.cnc-07__count {
  margin-top: 18px;
}

.cnc-07__digits {
  display: flex;
  gap: 2px;
  font-size: clamp(48px,13cqw,62px);
  font-weight: 770;
  letter-spacing: -.05em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-07__cell {
  position: relative;
  display: block;
  height: 1em;
  overflow: hidden;
}

.cnc-07__strip {
  display: block;
  transform: translateY(var(--cnc-07-y,0));
  transition: transform .55s cubic-bezier(.16,1,.3,1);
}

.cnc-07__strip i {
  display: block;
  height: 1em;
  font-style: normal;
  text-align: center;
}

.cnc-07__label {
  margin-top: 8px;
  font-size: 13.5px;
  color: var(--cnc-07-mut);
}

.cnc-07__spark {
  display: block;
  width: 100%;
  height: 44px;
  margin-top: 14px;
  overflow: visible;
}

.cnc-07__sparkline {
  fill: none;
  stroke: var(--cnc-07-acc);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
  opacity: .9;
}

.cnc-07__foot {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid oklch(1 0 0/.09);
  font-size: 12px;
  color: var(--cnc-07-mut);
}

.cnc-07__stat b {
  color: var(--cnc-07-ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

@container (max-width: 260px) {
  .cnc-07__spark,
    .cnc-07__foot {
    display: none;
  }
}

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

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

@media (prefers-reduced-motion: reduce) {
  .cnc-07__dot::after {
    animation: none;
    opacity: 0;
  }

  .cnc-07__strip {
    transition-duration: .01ms;
  }
}
(() => {
  document.querySelectorAll('.cnc-07__widget').forEach((w) => {
    if (w.dataset.wired) return;
    w.dataset.wired = '1';
    const wrap = w.querySelector('.cnc-07__digits');
    const line = w.querySelector('.cnc-07__sparkline');
    const peakEl = w.querySelector('.cnc-07__peak');
    const say = w.querySelector('.cnc-07__sr');
    let n = 31, peak = 48, history = Array.from({ length: 30 }, () => 24 + Math.round(Math.random() * 12)), spoke = 0;

    const buildCells = (len) => {
      wrap.innerHTML = '';
      for (let i = 0; i < len; i++) {
        const cell = document.createElement('span');
        cell.className = 'cnc-07__cell';
        const strip = document.createElement('span');
        strip.className = 'cnc-07__strip';
        strip.innerHTML = '0123456789'.split('').map((d) => '<i>' + d + '</i>').join('');
        cell.appendChild(strip);
        wrap.appendChild(cell);
      }
    };

    const setCount = (v) => {
      const s = String(v);
      if (wrap.children.length !== s.length) buildCells(s.length);
      [...s].forEach((d, i) => {
        const cell = wrap.children[i];
        if (cell.dataset.d === d) return;                 // unchanged digit stays put
        cell.dataset.d = d;
        cell.firstChild.style.setProperty('--cnc-07-y', -d * 10 + '%');
      });
      if (v > peak) { peak = v; peakEl.textContent = v; }
      history.push(v); history.shift();
      line.setAttribute('points', history.map((h, i) => (i / (history.length - 1)) * 200 + ',' + (42 - (h / Math.max(...history, 1)) * 38)).join(' '));
      if (performance.now() - spoke > 10000) { say.textContent = v + ' people viewing this page'; spoke = performance.now(); }
    };

    setCount(n);
    const id = setInterval(() => {
      if (document.visibilityState !== 'visible') return;
      n = Math.max(6, n + Math.round((Math.random() - 0.46) * 7));
      setCount(n);
    }, 2200);
    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: Live Visitor / Online User Count Widget
Source: https://codefronts.com/motion/css-number-counter-animations/live-visitor-online-user-count-widget/

The "32 people are viewing this right now" widget, built the way a real-time product should build it: a per-digit roll so only the digits that changed move, a breathing live dot, a sparkline of the last 30 seconds, and an aria-live region that speaks at most once every ten seconds no matter how often the socket fires.
## HTML
```html
<section class="cnc-07" aria-label="Live visitor count widget demo">
  <div class="cnc-07__stage">
    <div class="cnc-07__widget">
      <header class="cnc-07__top">
        <span class="cnc-07__live"><span class="cnc-07__dot" aria-hidden="true"></span>Live now</span>
        <span class="cnc-07__src">codefronts.com</span>
      </header>
      <p class="cnc-07__count" aria-hidden="true"><span class="cnc-07__digits"></span></p>
      <p class="cnc-07__label">people viewing this page</p>
      <svg class="cnc-07__spark" viewBox="0 0 200 44" preserveAspectRatio="none" aria-hidden="true">
        <polyline class="cnc-07__sparkline" points=""/>
      </svg>
      <footer class="cnc-07__foot">
        <span class="cnc-07__stat"><b class="cnc-07__peak">48</b> peak today</span>
        <span class="cnc-07__stat"><b class="cnc-07__avg">27</b> 24h average</span>
      </footer>
      <p class="cnc-07__sr" role="status" aria-live="polite"></p>
    </div>
    <p class="cnc-07__chip" aria-hidden="true">per-digit translateY strips · unchanged digits untouched · 10s throttled live region</p>
  </div>
</section>
```
## CSS
```css
.cnc-07,
.cnc-07 *,
.cnc-07 *::before,
.cnc-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-07 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-07-bg: oklch(0.15 0.018 250);
  --cnc-07-card: oklch(0.21 0.022 250);
  --cnc-07-ink: oklch(0.98 0.004 250);
  --cnc-07-mut: oklch(0.66 0.02 250);
  --cnc-07-acc: oklch(0.8 0.16 145);
  background: var(--cnc-07-bg);
  color: var(--cnc-07-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-07__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(18px,4vw,48px);
}

.cnc-07__widget {
  container-type: inline-size;
  width: min(100%,340px);
  padding: 20px;
  border-radius: 20px;
  background: var(--cnc-07-card);
  box-shadow: inset 0 0 0 1px oklch(1 0 0/.08),0 22px 54px oklch(0 0 0/.45);
}

.cnc-07__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 11.5px;
}

.cnc-07__live {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 10px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--cnc-07-acc) 15%,transparent);
  color: var(--cnc-07-acc);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.cnc-07__dot {
  position: relative;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}

.cnc-07__dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: currentColor;
  animation: cnc-07-ping 1.8s cubic-bezier(0,0,.2,1) infinite;
}

@keyframes cnc-07-ping {
  0% {
    opacity: .7;
    scale: 1;
  }

  80%,
    100% {
    opacity: 0;
    scale: 3.4;
  }
}

.cnc-07__src {
  color: var(--cnc-07-mut);
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.cnc-07__count {
  margin-top: 18px;
}

.cnc-07__digits {
  display: flex;
  gap: 2px;
  font-size: clamp(48px,13cqw,62px);
  font-weight: 770;
  letter-spacing: -.05em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-07__cell {
  position: relative;
  display: block;
  height: 1em;
  overflow: hidden;
}

.cnc-07__strip {
  display: block;
  transform: translateY(var(--cnc-07-y,0));
  transition: transform .55s cubic-bezier(.16,1,.3,1);
}

.cnc-07__strip i {
  display: block;
  height: 1em;
  font-style: normal;
  text-align: center;
}

.cnc-07__label {
  margin-top: 8px;
  font-size: 13.5px;
  color: var(--cnc-07-mut);
}

.cnc-07__spark {
  display: block;
  width: 100%;
  height: 44px;
  margin-top: 14px;
  overflow: visible;
}

.cnc-07__sparkline {
  fill: none;
  stroke: var(--cnc-07-acc);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
  opacity: .9;
}

.cnc-07__foot {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid oklch(1 0 0/.09);
  font-size: 12px;
  color: var(--cnc-07-mut);
}

.cnc-07__stat b {
  color: var(--cnc-07-ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

@container (max-width: 260px) {
  .cnc-07__spark,
    .cnc-07__foot {
    display: none;
  }
}

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

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

@media (prefers-reduced-motion: reduce) {
  .cnc-07__dot::after {
    animation: none;
    opacity: 0;
  }

  .cnc-07__strip {
    transition-duration: .01ms;
  }
}
```

## JavaScript
```js
(() => {
  document.querySelectorAll('.cnc-07__widget').forEach((w) => {
    if (w.dataset.wired) return;
    w.dataset.wired = '1';
    const wrap = w.querySelector('.cnc-07__digits');
    const line = w.querySelector('.cnc-07__sparkline');
    const peakEl = w.querySelector('.cnc-07__peak');
    const say = w.querySelector('.cnc-07__sr');
    let n = 31, peak = 48, history = Array.from({ length: 30 }, () => 24 + Math.round(Math.random() * 12)), spoke = 0;

    const buildCells = (len) => {
      wrap.innerHTML = '';
      for (let i = 0; i < len; i++) {
        const cell = document.createElement('span');
        cell.className = 'cnc-07__cell';
        const strip = document.createElement('span');
        strip.className = 'cnc-07__strip';
        strip.innerHTML = '0123456789'.split('').map((d) => '<i>' + d + '</i>').join('');
        cell.appendChild(strip);
        wrap.appendChild(cell);
      }
    };

    const setCount = (v) => {
      const s = String(v);
      if (wrap.children.length !== s.length) buildCells(s.length);
      [...s].forEach((d, i) => {
        const cell = wrap.children[i];
        if (cell.dataset.d === d) return;                 // unchanged digit stays put
        cell.dataset.d = d;
        cell.firstChild.style.setProperty('--cnc-07-y', -d * 10 + '%');
      });
      if (v > peak) { peak = v; peakEl.textContent = v; }
      history.push(v); history.shift();
      line.setAttribute('points', history.map((h, i) => (i / (history.length - 1)) * 200 + ',' + (42 - (h / Math.max(...history, 1)) * 38)).join(' '));
      if (performance.now() - spoke > 10000) { say.textContent = v + ' people viewing this page'; spoke = performance.now(); }
    };

    setCount(n);
    const id = setInterval(() => {
      if (document.visibilityState !== 'visible') return;
      n = Math.max(6, n + Math.round((Math.random() - 0.46) * 7));
      setCount(n);
    }, 2200);
    addEventListener('pagehide', () => clearInterval(id), { once: true });
  });
})();
```

How this works

Rolling the whole number when only the last digit changed looks wrong. Split the value into digit cells and animate each one independently:

const cells = String(value).padStart(len,'0').split('');
cells.forEach((d, i) => {
  const cell = digits[i];
  if (cell.dataset.d === d) return;            // unchanged → don't touch it
  cell.dataset.d = d;
  cell.style.setProperty('--cnc-07-y', -d * 10 + '%');
});
.strip { transform: translateY(var(--cnc-07-y)); transition: transform .55s cubic-bezier(.16,1,.3,1); }

Each cell holds a vertical strip of 0–9 in a 10× tall column; setting --cnc-07-y to -d · 10% slides that digit into the window. Because unchanged cells are skipped entirely, a 31 → 32 update moves one digit, exactly like a departure board.

if (performance.now() - lastSpoke > 10000) { live.textContent = n + ' people viewing'; lastSpoke = performance.now(); }

Throttling the live region is the accessibility rule that matters most for real-time UI: the value on screen can change every second, but a screen reader user needs a readable cadence, not a stream.

Make it yours

  • Wire to a real socket: replace the simulated tick with ws.onmessage = e => setCount(JSON.parse(e.data).online) — the render path is already idempotent.
  • Direction cue: colour the roll green when the count rises and amber when it falls by setting a data-dir attribute before the transition.
  • Compact chip version: hide the sparkline and label under a container query (@container (max-width: 260px)) for sidebar placements.
  • Country breakdown: the widget's flex row accepts a second line of flags/percentages without touching the digit logic.

Gotchas — read before shipping

  • overflow:hidden on the digit window is mandatory; without it the whole 0-9 strip paints and you see a column of numbers.
  • Don't rebuild the digit DOM on every update — recycling cells is what keeps unchanged digits still. Rebuild only when the digit count changes.
  • aria-live on a rapidly-changing node floods the screen reader buffer. Throttle to ≥10s, or use aria-live="off" plus an on-demand 'refresh' button.
  • Simulated 'live' counters that only ever go up are transparently fake to users. Model both directions with realistic variance.
  • Background tabs throttle timers to ≥1s (and much lower when hidden); reconcile from the server value on visibilitychange rather than assuming the local count kept up.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

translateY digit strips work everywhere. Container queries (used for the compact variant) need Chrome 105 / Safari 16 / Firefox 110; the layout is fine without them.

Techniques used in this demo

Search CodeFronts

Loading…