Stats count-up
Numbers count from zero when the block enters. CSS counters + a registered property. The transition IS the animation.
- client sites shipped
- median Lighthouse performance
- KB of engine, gzipped
// ScrollVars fx · stats-countup
// Requires: npm i scrollvars · import 'scrollvars/styles/core.css' and 'scrollvars/styles/state.css'
// state.css owns sv-acts; core.css declares --sv-live, and the acts clock is
// calc(var(--sv-live) * var(--sv-acts-count)): without core.css every number
// renders 0 the moment the driver boots.
// Numbers count from zero when the block enters: a CSS counter driven by the
// registered --sv-act transition. No JS, no innerText. Without JS or under
// reduced motion the final numbers render immediately.
// The CSS below is one constant string, injected as raw HTML: a <style> child
// is escaped by React 18's server renderer (`>` becomes `>`, dropping every
// child-combinator rule), and <style> is raw text, so the entity never decodes.
// CSP is unchanged from any inline <style>: allow it with a style-src nonce or
// hash, or lift this string into your own stylesheet.
'use client'
import * as React from 'react'
import { Track } from 'scrollvars/react'
const css = `
.sv-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 18px; margin: 0; }
.sv-stats > div { display: flex; flex-direction: column; padding: 22px 12px; border-radius: 14px; border: 1px solid rgba(128,128,128,.25); text-align: center; }
.sv-stats dt { order: 2; font-size: 13px; margin-top: 8px; opacity: .7; }
.sv-stats dd { margin: 0; font-size: clamp(34px, 6vw, 64px); line-height: 1; font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.sv-stats .stat { counter-reset: n calc(var(--sv-act, 1) * var(--sv-max)); }
.sv-stats .stat .count::after { content: counter(n) attr(data-suffix); }
html:not(.sv-on) .sv-stats .stat { counter-reset: n var(--sv-max); }
@media (prefers-reduced-motion: reduce) { .sv-stats .stat { counter-reset: n var(--sv-max); } }
:where([data-sv-motion="reduce"]) .sv-stats .stat { counter-reset: n var(--sv-max); }
`
// aria-label is prohibited on generic roles (p/span/div). Axe
// `aria-prohibited-attr`: so the readable text is a visually-hidden child.
const SR_ONLY: React.CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
}
export interface Stat {
label: React.ReactNode
value: number
suffix?: string
}
export function StatsCountup({
stats,
duration = 1.8,
className,
nonce,
}: {
stats: Stat[]
/** Seconds the count takes. */
duration?: number
className?: string
nonce?: string
}) {
return (
<Track
once
className={className ? 'sv-acts ' + className : 'sv-acts'}
style={{ '--sv-acts-count': 1, '--sv-acts-duration': duration + 's' } as React.CSSProperties}
>
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
<dl className="sv-stats">
{stats.map((s, i) => (
<div key={i}>
<dt>{s.label}</dt>
{/* both halves sit INSIDE the dd: a dl group takes dt/dd only (a loose
span there is not in the content model), and a term whose only dd
is aria-hidden reaches assistive tech with no definition at all */}
<dd className="stat" style={{ '--sv-max': s.value } as React.CSSProperties}>
<span style={SR_ONLY}>{s.value}{s.suffix ?? ''}</span>
<span className="count" data-suffix={s.suffix ?? ''} aria-hidden="true" />
</dd>
</div>
))}
</dl>
</Track>
)
}