Lunia DS

Marketing

Stats band

A row of key figures that waits at zero until it scrolls into view, then draws the lines between the figures and rolls each number up in turn as its label rises in.

Loading preview

Four figures in a band: 12,400 teams, 98.9% uptime, 3.2M tasks done each month (compact format) and 40 countries. The band starts below the fold of a short scrolling box; Replay resets it to zeros and plays the arrival again.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add stats-band

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { type Stat, StatsBand } from './stats-band';

const STATS: Stat[] = [
  { value: 12_400, label: 'Teams on board' },
  {
    value: 98.9,
    label: 'Uptime this year',
    suffix: '%',
    format: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
  },
  {
    value: 3_200_000,
    label: 'Monthly tasks',
    format: { notation: 'compact', maximumFractionDigits: 1 },
  },
  { value: 40, label: 'Countries' },
];

export default function StatsBandDemo({ replay = 0 }: { replay?: number }) {
  const box = React.useRef<HTMLElement>(null);

  // Replay resets the band to zeros above the fold, then scrolls it into view so the arrival plays again.
  React.useEffect(() => {
    const el = box.current;
    if (replay === 0 || !el) return;
    el.scrollTop = 0;
    // The rolling digits are taller than the figure, so the scroll goes to the band and not to the end of the box.
    const timer = window.setTimeout(() => {
      const band = el.querySelector('[data-slot="stats-band"]');
      if (band)
        el.scrollTo({
          top: band.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop - 40,
          behavior: 'smooth',
        });
    }, 500);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    <section
      ref={box}
      data-slot="stats-band-demo"
      // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll box must take keyboard focus so it can be scrolled without a mouse.
      tabIndex={0}
      aria-label="Scroll area"
      className="h-[240px] w-full max-w-[720px] overflow-y-auto rounded-xl border border-secondary bg-primary outline-none focus-visible:shadow-focus-ring"
    >
      <div className="flex h-[185px] items-center justify-center px-6 text-sm text-tertiary">
        Scroll down to see the figures
      </div>
      <div className="overflow-hidden px-6 pb-12">
        <StatsBand key={replay} stats={STATS} />
      </div>
    </section>
  );
}

API

Parts: root (description list; 2 columns on small screens, one row from the sm breakpoint), stat, divider line (before every stat but the first, wide screens only), figure (number roll, aria-hidden), figure text (screen reader only), label.

PropTypeDefaultDescription
statsStat[]-The figures, each { value, label, format?, prefix?, suffix? }; format takes Intl.NumberFormat options such as { notation: "compact" }. Required.
staggernumber140Time in ms between one stat starting and the next.
numberClassNamestringdisplay-md, medium weight, tight tracking, text-primaryClasses for the figures, to change their size or color.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
STB1half of the band first scrolls into viewdivider linetransform scaleY (drawn from the middle out)0 → 1duration-5xl (640)ease-outdelay stagger-step (80) x statShows at once
STB2half of the band first scrolls into viewfigurenumber roll0 → valueduration-6xl (900)ease-outdelay 240 + 140 x statSwaps at once
STB3half of the band first scrolls into viewlabelopacity0 → 1duration-4xl (520)ease-outdelay 240 + 140 x stat + 120Shows at once
STB4half of the band first scrolls into viewlabeltransform translateYdistance-nudge → 0duration-5xl (640)ease-outdelay 240 + 140 x stat + 120Moves at once
STB5a value changes after the revealfigurenumber rollold value → new valueduration-6xl (900)ease-outSwaps at once

Accessibility

  • The band is a description list: each label is a dt and each figure a dd, so the pairs are read together.
  • The rolling digits are aria-hidden; a screen-reader-only copy of the formatted final value (with prefix and suffix) is there from the start, so the real figures are read even before the band is seen.
  • Divider lines are aria-hidden.
  • Reduced motion: all figures, labels and lines show their final state at once when the band is seen, with no roll, rise or draw.