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.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);
}(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);
})();<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>