Stats band
A row of headline numbers, with tabular figures and a count-up that respects reduced motion.
<section class="stats">
<div class="inner">
<div class="stat">
<dl>
<dd><span class="num" data-to="2400000" data-format="compact">0</span></dd>
<dt>Addresses indexed</dt>
</dl>
</div>
<div class="stat">
<dl>
<dd><span class="num" data-to="40" data-suffix="ms">0</span></dd>
<dt>Median response</dt>
</dl>
</div>
<div class="stat">
<dl>
<dd><span class="num" data-to="99.98" data-decimals="2" data-suffix="%">0</span></dd>
<dt>Uptime, twelve months</dt>
</dl>
</div>
<div class="stat">
<dl>
<dd><span class="num" data-to="1800" data-prefix="+">0</span></dd>
<dt>Teams building on it</dt>
</dl>
</div>
</div>
</section>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; }
.stats {
background: #101b2d;
background-image: radial-gradient(70% 120% at 50% 0%, rgba(59, 79, 228, .35), transparent 60%);
color: #fff;
padding: 54px 24px;
}
.inner {
max-width: 1040px;
margin: 0 auto;
display: grid;
gap: 28px;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
text-align: center;
}
.stat + .stat { border-left: 1px solid rgba(255, 255, 255, .1); }
dl { margin: 0; }
dd {
margin: 0;
font-size: clamp(2rem, 1.4rem + 2vw, 2.9rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
/* every digit the same width, so the number does not jitter as it counts */
font-variant-numeric: tabular-nums;
}
dt {
margin-top: 9px;
font-size: .87rem;
color: #9aaac3;
}
@media (max-width: 700px) {
.inner { grid-template-columns: repeat(2, 1fr); gap: 30px 16px; }
.stat + .stat { border-left: 0; }
.stat:nth-child(even) { border-left: 1px solid rgba(255, 255, 255, .1); }
}const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
function format(el, value) {
const dp = Number(el.dataset.decimals || 0);
const body = el.dataset.format === "compact"
? new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value)
: new Intl.NumberFormat(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp }).format(value);
el.textContent = (el.dataset.prefix || "") + body + (el.dataset.suffix || "");
}
function countUp(el) {
const to = Number(el.dataset.to);
// no animation for anyone who asked not to have one: show the final figure
if (reduced) { format(el, to); return; }
const ms = 1100;
const start = performance.now();
function frame(now) {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
format(el, to * eased);
if (t < 1) requestAnimationFrame(frame);
else format(el, to); // land exactly on the target
}
requestAnimationFrame(frame);
}
// only count when the band is actually on screen
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
countUp(e.target);
obs.unobserve(e.target); // once, not every scroll
});
}, { threshold: 0.4 });
document.querySelectorAll(".num").forEach((el) => io.observe(el));How it works
Each stat is a description list. The number is the value and the label describes it, which is exactly what <dl>, <dt> and <dd> are for. Two divs would render identically and tell assistive technology nothing about the relationship.
The number comes before the label visually — big figure on top — but <dt> must come before <dd> in the markup order this pattern expects. Here the <dd> is first, which is technically unusual; if strict validity matters to you, put the <dt> first and reverse them visually with flex-direction: column-reverse.
tabular-nums is not optional here. Most typefaces give digits different widths by default, so a counting number visibly jitters as it changes. One property fixes it, and it is the single most noticeable difference between a polished counter and a cheap one.
Intl.NumberFormat, not string concatenation. It handles thousands separators in the reader's own locale, compact notation ("2.4M"), and decimal places — without you writing any of it. Hand-rolled formatting gets commas wrong for most of the world.
IntersectionObserver, and unobserve after firing. The count runs when the band scrolls into view rather than on page load, so nobody arrives after the animation has already finished. Unobserving means it does not restart every time you scroll past.
Reduced motion shows the final number immediately. Not a shorter animation, and certainly not a blank space — the figure is the content, and it must be there regardless.
Accessibility notes
The final value is in data-to and the visible text starts at zero. If JavaScript does not run, the band shows zeros — so for a page where that matters, put the real figure in the markup and have the script read it before animating.
Do not use aria-live on a counting number. It would announce every intermediate value, which is unusable. The figure is announced when the user reaches it, which is the right behaviour.
Contrast on the label colour matters: #9aaac3 on #101b2d clears the threshold. Muted grey labels on dark backgrounds are one of the most commonly failed checks.
Making it yours
Four stats fits comfortably; five is usually one too many. On mobile they go to two columns rather than one, because a single column of four huge numbers is a lot of scrolling for very little information.
Numbers need to be true and current. A stat that was accurate two years ago is worse than no stat, and "10,000+ happy customers" with no source reads as filler to exactly the audience you are trying to convince.
Related templates
Check your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.