Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
titleReactNodeThe section heading on StatStory, and its accessible name.
pacenumber1Scroll distance per fact on StatStory, as a multiple of the viewport height.
valuenumberThe final number on StatStoryItem, required and positive.
decimalsnumber0Decimal places, fixed through the count.
prefixstringShown before the number, such as a currency sign.
suffixstringShown after the number at a smaller size, such as a unit.
labelstringShort mono label above the number.
childrenReactNodeThe 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.