Stats band

Numbers that arrive when you do: hairlines draw themselves, then each figure rolls up from zero in turn.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/stats-band.json

Overview

A row of big numbers is proof, but a static one gets scrolled past. This band waits until it is on screen. Then the hairlines between the stats draw themselves from the middle out, and each figure rolls up from zero a beat after the last, digit by digit, as its label rises in. Any format works: compact ("12.5K+"), decimals ("99.98%"), prefixes and suffixes. Screen readers get the real figures straight away.

States

waiting
Off screen: quiet zeros and undrawn lines, so nothing has already happened by the time you arrive.
arriving
At half visible, the dividers draw over 700ms, then each stat rolls up from 0 (1.2s), 140ms after the one before.
done
The real numbers. Change a value later and only its changed digits roll.

Usage

import { StatsBand } from "@/components/stats-band";

<StatsBand
  stats={[
    { value: 12480, label: "Teams shipping", format: { notation: "compact" }, suffix: "+" },
    { value: 99.98, label: "Uptime", suffix: "%" },
  ]}
/>

Works with

In a proof section

Fetch the numbers on the server; the band animates them when the visitor gets there.

import { StatsBand } from "@/components/stats-band";

export default async function Proof() {
  const { teams, uptime, rating } = await getPublicStats(); // your data, cached

  return (
    <section className="mx-auto max-w-5xl border-y">
      <StatsBand
        stats={[
          { value: teams, label: "Teams shipping on Relay", format: { notation: "compact", maximumFractionDigits: 1 }, suffix: "+" },
          { value: uptime, label: "Uptime this year", format: { maximumFractionDigits: 2 }, suffix: "%" },
          { value: rating, label: "Average review", format: { minimumFractionDigits: 1 }, suffix: " / 5" },
        ]}
      />
    </section>
  );
}

Props

PropTypeDescription
stats{ value; label; format?; prefix?; suffix? }[]Each figure and its label. format takes Intl.NumberFormat options.
staggernumberdefault 140Time between one stat starting and the next (ms).
numberClassNamestringSize and weight of the figures.

Accessibility and motion

  • A description list: each label is a dt and its figure a dd, with the real number as text for screen readers and search.
  • It runs once, the first time half the band is visible, using an IntersectionObserver; no scroll listeners.
  • Installs Number roll alongside it.
  • With reduced motion the numbers and lines are simply there.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Number roll.

Source

›Show the full source of stats-band.tsx (126 lines)
"use client";

import * as React from "react";
import { NumberRoll } from "./number-roll";

/* ─────────────────────────────────────────────────────────
 * STATS BAND: the numbers arrive when you do
 *
 *   waiting   before it's on screen, quiet zeros
 *   arriving  the hairlines between stats draw themselves,
 *             then each figure rolls up from zero, one after
 *             another, as its label rises in
 *   done      the real numbers, rolling again only if they
 *             change
 *
 * Screen readers get the real figures straight away.
 * ───────────────────────────────────────────────────────── */

export interface Stat {
  value: number;
  label: string;
  /** Intl.NumberFormat options, e.g. { notation: "compact" }. */
  format?: Intl.NumberFormatOptions;
  prefix?: string;
  suffix?: string;
}

export interface StatsBandProps extends React.HTMLAttributes<HTMLDListElement> {
  stats: Stat[];
  /** Time between one stat starting and the next (ms). */
  stagger?: number;
  /** Classes for the figures. */
  numberClassName?: string;
}

const EASE = "cubic-bezier(0.16,1,0.3,1)";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

export function StatsBand({
  stats,
  stagger = 140,
  numberClassName = "text-[40px] font-medium leading-[1.15] tracking-[-0.04em] text-foreground",
  className = "",
  ...props
}: StatsBandProps) {
  const reduced = useReducedMotion();
  const ref = React.useRef<HTMLDListElement>(null);
  const [seen, setSeen] = React.useState(false);
  const [revealed, setRevealed] = React.useState(0);

  // Start once, the first time most of the band is on screen.
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setSeen(true);
        observer.disconnect();
      },
      { threshold: 0.5 }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // Then reveal the stats one after another: the lines draw first, the numbers follow.
  React.useEffect(() => {
    if (!seen) return;
    if (reduced) {
      setRevealed(stats.length);
      return;
    }
    const timers = stats.map((_, i) => window.setTimeout(() => setRevealed((r) => Math.max(r, i + 1)), 240 + i * stagger));
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [seen, reduced, stats, stagger]);

  return (
    <dl ref={ref} className={`grid grid-cols-2 sm:grid-flow-col sm:grid-cols-none sm:auto-cols-fr ${className}`} {...props}>
      {stats.map((stat, i) => {
        const shown = i < revealed;
        return (
          <div key={stat.label} className="relative flex flex-col-reverse justify-end px-6 py-5">
            {/* The hairline before every stat but the first, drawn from the middle out (wide screens). */}
            {i > 0 && (
              <span
                aria-hidden="true"
                className="absolute left-0 top-0 hidden h-full w-px origin-center bg-border sm:block"
                style={{ transform: seen || reduced ? "none" : "scaleY(0)", transition: reduced ? "none" : `transform 700ms ${EASE} ${i * 80}ms` }}
              />
            )}
            <dt
              className="mt-1 text-[13px] text-muted-foreground"
              style={{
                opacity: shown ? 1 : 0,
                transform: shown ? "none" : "translateY(6px)",
                transition: reduced ? "none" : `opacity 500ms ${EASE} 120ms, transform 600ms ${EASE} 120ms`,
              }}
            >
              {stat.label}
            </dt>
            <dd>
              <span className="sr-only">
                {stat.prefix}
                {new Intl.NumberFormat(undefined, stat.format).format(stat.value)}
                {stat.suffix}
              </span>
              <span aria-hidden="true">
                <NumberRoll value={shown ? stat.value : 0} format={stat.format} prefix={stat.prefix} suffix={stat.suffix} duration={1200} className={numberClassName} />
              </span>
            </dd>
          </div>
        );
      })}
    </dl>
  );
}

Registry item: /r/stats-band.json