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.
Live preview
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-bandUsage
The demo above, as code.
'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.
| Prop | Type | Default | Description |
|---|---|---|---|
| stats | Stat[] | - | The figures, each { value, label, format?, prefix?, suffix? }; format takes Intl.NumberFormat options such as { notation: "compact" }. Required. |
| stagger | number | 140 | Time in ms between one stat starting and the next. |
| numberClassName | string | display-md, medium weight, tight tracking, text-primary | Classes 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| STB1 | half of the band first scrolls into view | divider line | transform scaleY (drawn from the middle out) | 0 → 1 | duration-5xl (640)ease-outdelay stagger-step (80) x stat | Shows at once |
| STB2 | half of the band first scrolls into view | figure | number roll | 0 → value | duration-6xl (900)ease-outdelay 240 + 140 x stat | Swaps at once |
| STB3 | half of the band first scrolls into view | label | opacity | 0 → 1 | duration-4xl (520)ease-outdelay 240 + 140 x stat + 120 | Shows at once |
| STB4 | half of the band first scrolls into view | label | transform translateY | distance-nudge → 0 | duration-5xl (640)ease-outdelay 240 + 140 x stat + 120 | Moves at once |
| STB5 | a value changes after the reveal | figure | number roll | old value → new value | duration-6xl (900)ease-out | Swaps 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.