← Back to library

Count-up stats

Numbers animate from 0 to target on scroll into view.

Three (or more) big stat numbers count up from zero when they scroll into view. Uses IntersectionObserver — no GSAP dependency. Eased cubic decay for satisfying landing. Tabular nums prevent layout shift.

count-up · intensity bold

Base-catalogue pattern — no generated axes.

Generation prompts are part of a CreativePower subscription, and are never public.

See plans

Stylesheet

css · 1214 chars
.cu-stage {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4rem 6%;
  /* stageMode "adaptive": flat type on the page surface, no additive light. */
  --cu-surface: var(--color-surface, #08090b);
  /* The number is the one saturated mark here, mixed toward the surface before it is
     painted so a dark or cool primary doesn't land dimmer than the literal assumed. */
  --cu-accent: color-mix(in oklab, var(--color-primary, #ff6b1a) 80%, var(--color-surface, #ff6b1a));
  --cu-ink-dim: color-mix(in oklab, var(--color-on-surface, #9a9da4) 62%, var(--color-surface, #9a9da4));
  background: var(--cu-surface);
}
.cu-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4rem;
  max-width: 1000px;
}
.cu-stat { text-align: center; }
.cu-number {
  font-family: "Geist", system-ui, sans-serif;
  font-size: clamp(3rem, 8vw, 6rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  color: var(--cu-accent);
  line-height: 1;
  margin-bottom: 0.6rem;
  font-variant-numeric: tabular-nums;
}
.cu-label {
  font-family: "Geist Mono", monospace;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--cu-ink-dim);
}

Script

js · 2190 chars
(function() {
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var nums = Array.prototype.slice.call(document.querySelectorAll('.cu-number'));
  if (!nums.length) return;

  if (reduce) {
    nums.forEach(function(el){ el.textContent = (parseInt(el.getAttribute('data-target'),10)||0).toLocaleString() + (el.getAttribute('data-suffix')||''); });
    return;
  }

  function animateCount(el) {
    var target = parseInt(el.getAttribute('data-target'), 10) || 0;
    var suffix = el.getAttribute('data-suffix') || '';
    var duration = 1600;
    var start = performance.now();
    function tick(now) {
      var t = Math.min((now - start) / duration, 1);
      var eased = 1 - Math.pow(1 - t, 3);
      el.textContent = Math.floor(target * eased).toLocaleString() + suffix;
      if (t < 1) requestAnimationFrame(tick);
      else el.textContent = target.toLocaleString() + suffix;
    }
    requestAnimationFrame(tick);
  }

  function playAll() { nums.forEach(animateCount); }
  function resetAll() { nums.forEach(function(el){ el.textContent = '0'; }); }

  var hasPlayed = false;
  function playOnce() { if (hasPlayed) return; hasPlayed = true; playAll(); /* cp:demo-start */startDemoLoopIfNeeded();/* cp:demo-end */ }

  /* cp:demo-start */
  // Demo loop: in the showcase, re-count on a cycle so the motion is always visible.
  function startDemoLoopIfNeeded() {
    if (!window.__CP_DEMO__) return;
    setInterval(function(){
      if (window.__cpPaused) return;       // honor pause bridge when offscreen
      resetAll();
      setTimeout(playAll, 250);
    }, 3600);
  }
  /* cp:demo-end */

  // Reliable trigger: IntersectionObserver, with a load fallback if it never fires.
  var triggered = false;
  function fire() { if (triggered) return; triggered = true; playOnce(); }
  try {
    var io = new IntersectionObserver(function(entries){
      entries.forEach(function(e){ if (e.isIntersecting) { fire(); } });
    }, { threshold: 0.25 });
    nums.forEach(function(el){ io.observe(el); });
  } catch (err) { /* no IO — fall through to timer */ }
  // Fallback: if IO hasn't fired shortly after load, count anyway.
  setTimeout(fire, 600);
})();

Markup

html · 543 chars
<section class="cu-stage">
  <div class="cu-grid">
    <div class="cu-stat">
      <div class="cu-number" data-target="14000">0</div>
      <div class="cu-label">━━ Pages shipped</div>
    </div>
    <div class="cu-stat">
      <div class="cu-number" data-target="847" data-suffix="ms">0</div>
      <div class="cu-label">━━ Avg generation time</div>
    </div>
    <div class="cu-stat">
      <div class="cu-number" data-target="98" data-suffix="%">0</div>
      <div class="cu-label">━━ Brand consistency</div>
    </div>
  </div>
</section>