ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Stats count-up

Numbers count from zero when the block enters. CSS counters + a registered property. The transition IS the animation.

Use it for: Proof strips ("248 sites shipped"), pricing pages, investor-style KPI rows.
Knobs: --sv-max per number, --sv-acts-duration (count time), data-suffix ("%", "+", "k"), --sv-acts-count stays 1

client sites shipped
248+
median Lighthouse performance
99
KB of engine, gzipped
4

Install: npx scrollvars add stats-countup
Styles: import 'scrollvars/styles/core.css' · import 'scrollvars/styles/state.css'

// 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 `&gt;`, 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>
  )
}

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.