Stat story
A pinned stage where big numbers count up one at a time as you scroll.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/stat-story.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/stat-story
Four facts after a short lead-in. Each number counts up as it takes the stage.
Usage
import { StatStory, StatStoryItem } from "@/components/ui/stat-story"
<StatStory title="The year in numbers">
<StatStoryItem label="Uptime" value={99.9} decimals={1} suffix="%">
One grey line on what the number means.
</StatStoryItem>
</StatStory>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | The section heading on StatStory, and its accessible name. | |
| pace | number | 1 | Scroll distance per fact on StatStory, as a multiple of the viewport height. |
| value | number | The final number on StatStoryItem, required and positive. | |
| decimals | number | 0 | Decimal places, fixed through the count. |
| prefix | string | Shown before the number, such as a currency sign. | |
| suffix | string | Shown after the number at a smaller size, such as a unit. | |
| label | string | Short mono label above the number. | |
| children | ReactNode | The one grey line under the number. |
Source
stat-story/stat-story.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger);
const STAGE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";
/** Timeline units in one hand-over: hold the fact, fade it out, then bring the next one in and count. */
const REST = 0.35;
const LEAVE = 0.15;
const COUNT = 0.5;
const ENTER = 0.15;
const formatValue = (value: number, decimals: number) =>
value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
/** Writes one number. Unfilled places show as dim zeros, so the width never changes as it counts. */
function counterFor(item: HTMLElement) {
const zeros = item.querySelector("[data-slot=stat-story-zeros]");
const digits = item.querySelector("[data-slot=stat-story-digits]");
const target = Number(item.dataset.value);
const decimals = Number(item.dataset.decimals);
const template = formatValue(target, decimals).replace(/\d/g, "0");
const state = { value: 0 };
let shown = "";
const write = () => {
const text = formatValue(state.value, decimals);
if (text === shown || !zeros || !digits) return;
shown = text;
zeros.textContent = template.slice(0, Math.max(template.length - text.length, 0));
digits.textContent = text;
};
const finish = () => {
state.value = target;
write();
};
return { state, target, write, finish };
}
type StatStoryProps = Omit<React.ComponentProps<"section">, "title"> & {
/** What the numbers add up to. Rendered as the section heading. */
title?: React.ReactNode;
/** Scroll distance per fact, as a multiple of the viewport height. */
pace?: number;
};
/**
* A pinned stage that walks through a few big numbers, counting each one up as you scroll. The numbers
* are in the markup at their final value, and below md or under reduced motion this is a plain grid.
*/
function StatStory({ title, pace = 1, className, children, ...props }: StatStoryProps) {
const root = React.useRef<HTMLElement>(null);
const total = React.Children.count(children);
const headingId = React.useId();
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(STAGE, () => {
const items = gsap.utils.toArray<HTMLElement>("[data-slot=stat-story-item]");
const fills = gsap.utils.toArray<HTMLElement>("[data-slot=stat-story-fill]");
const counters = items.map(counterFor);
if (!items.length) return;
const legs = items.length - 1;
const length = legs + REST;
// The first number counts on the way in, so a stage at the top of a page simply starts finished.
gsap.to(counters[0].state, {
value: counters[0].target,
ease: "none",
onUpdate: counters[0].write,
scrollTrigger: { trigger: root.current, start: "top 70%", end: "top top", scrub: true },
});
gsap.set(items.slice(1), { opacity: 0, y: 24 });
gsap.set(fills.slice(1), { scaleX: 0 });
counters.slice(1).forEach((counter) => counter.write());
const timeline = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: root.current,
pin: true,
start: "top top",
end: () => `+=${length * pace * window.innerHeight}`,
scrub: true,
},
});
items.slice(1).forEach((item, i) => {
const counter = counters[i + 1];
timeline
.to({}, { duration: REST })
.to(items[i], { opacity: 0, y: -24, duration: LEAVE })
.to(item, { opacity: 1, y: 0, duration: ENTER })
.to(counter.state, { value: counter.target, duration: COUNT, onUpdate: counter.write }, "<")
.to(fills[i + 1], { scaleX: 1, duration: COUNT }, "<");
});
timeline.to({}, { duration: REST });
return () => counters.forEach((counter) => counter.finish());
});
return () => media.revert();
},
{ scope: root, dependencies: [pace, total] },
);
return (
<section
ref={root}
data-slot="stat-story"
aria-labelledby={title ? headingId : undefined}
className={cn(
"relative bg-background md:motion-safe:not-noscript:h-dvh md:motion-safe:not-noscript:min-h-96 md:motion-safe:not-noscript:overflow-hidden",
className,
)}
{...props}
>
<div className="mx-auto flex h-full w-full max-w-6xl flex-col px-6 py-16 md:px-8 md:py-20 md:motion-safe:not-noscript:py-14">
{title ? (
<h2
id={headingId}
className="m-0 max-w-xl text-3xl leading-none font-medium tracking-tight md:motion-safe:not-noscript:text-xl md:motion-safe:not-noscript:leading-7"
>
{title}
</h2>
) : null}
<ul className="relative m-0 mt-10 grid min-h-0 flex-1 list-none divide-y p-0 md:grid-cols-2 md:gap-x-16 md:divide-y-0 md:motion-safe:not-noscript:mt-0 md:motion-safe:not-noscript:block">
{children}
</ul>
<div aria-hidden="true" className="hidden gap-1 md:motion-safe:not-noscript:flex">
{Array.from({ length: total }, (_, index) => (
<div key={index} className="relative h-px flex-1 bg-border">
<div
data-slot="stat-story-fill"
className="absolute inset-0 origin-left bg-foreground"
style={index === 0 ? undefined : { transform: "scaleX(0)" }}
/>
</div>
))}
</div>
</div>
</section>
);
}
type StatStoryItemProps = Omit<React.ComponentProps<"li">, "prefix"> & {
/** The final number. Positive; put any sign in prefix. */
value: number;
/** Decimal places, fixed through the whole count. */
decimals?: number;
/** Shown before the number, such as a currency sign. */
prefix?: string;
/** Shown after the number at a smaller size, such as a unit. */
suffix?: string;
/** Short mono label above the number. */
label?: string;
};
/** One fact: a number and its one grey line, passed as children. */
function StatStoryItem({
value,
decimals = 0,
prefix,
suffix,
label,
className,
children,
...props
}: StatStoryItemProps) {
const text = formatValue(value, decimals);
return (
<li
data-slot="stat-story-item"
data-value={value}
data-decimals={decimals}
className={cn(
"py-8 md:py-10 md:motion-safe:not-noscript:absolute md:motion-safe:not-noscript:inset-0 md:motion-safe:not-noscript:grid md:motion-safe:not-noscript:grid-cols-[minmax(max-content,1fr)_minmax(0,1fr)] md:motion-safe:not-noscript:content-end md:motion-safe:not-noscript:items-end md:motion-safe:not-noscript:gap-x-16 md:motion-safe:not-noscript:py-8 md:motion-safe:not-noscript:not-first:opacity-0",
className,
)}
{...props}
>
{label ? (
<p className="m-0 mb-4 font-mono text-xs tracking-wide text-muted-foreground uppercase md:motion-safe:not-noscript:col-span-2">
{label}
</p>
) : null}
<p className="m-0 flex items-baseline gap-2 leading-none font-medium tracking-tight md:motion-safe:not-noscript:gap-4">
<span className="sr-only">
{prefix}
{text}
{suffix ? ` ${suffix}` : null}
</span>
<span
aria-hidden="true"
className="text-6xl tabular-nums md:motion-safe:not-noscript:text-8xl lg:motion-safe:not-noscript:text-9xl"
>
{prefix}
<span data-slot="stat-story-zeros" className="text-muted-foreground/40" />
<span data-slot="stat-story-digits">{text}</span>
</span>
{suffix ? (
<span aria-hidden="true" className="text-3xl text-muted-foreground md:motion-safe:not-noscript:text-5xl">
{suffix}
</span>
) : null}
</p>
<p className="m-0 mt-4 max-w-xl text-base text-muted-foreground md:motion-safe:not-noscript:mt-0 md:motion-safe:not-noscript:max-w-md md:motion-safe:not-noscript:pb-2 md:motion-safe:not-noscript:text-xl">
{children}
</p>
</li>
);
}
export { StatStory, StatStoryItem };
For AI agents
Hand your agent this page, the markdown at /components/stat-story/prompt.md, or the Copy AI prompt button above.
When to use
- Three or four facts that deserve a beat each
- A results section on a case study or annual review
- Once on a landing page, where the numbers are the argument
When not to use
- For figures people need to compare: use a stat grid or a table
- For more than four facts, which turns a story into a chore
- Inside a scaled or transformed parent, which breaks ScrollTrigger's measuring
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- Numbers render at their final value on the server. Screen readers always get that value from a hidden copy, never a value mid-count
- Below 768px, under reduced motion and with no script it is a plain grid of final values
- The first number counts as the stage scrolls in, so leave a lead-in above it. At the very top of a page it simply starts finished
- title labels the section. Without one, pass aria-label. It pins against the window, so it must not sit inside an overflow container or a CSS transform
The maths
- Places that are not filled yet show as dim zeros copied from the final value, and tabular figures make every digit one width, so the text is the same width at every frame.
- Each hand-over is 0.35 rest, 0.15 out, then 0.5 of count with the fade-in inside it, so no two facts are ever on screen together.
- Pin distance is ((n - 1) + 0.35) × pace × viewport height, so four facts at pace 1 scroll 3.35 screens.