# Stat reveal

> A stat counts up when it scrolls in.

- Type: Component (numbers)
- Page: https://textlab.dev/components/stat-reveal
- Markdown: https://textlab.dev/components/stat-reveal.md
- Registry item: https://textlab.dev/r/stat-reveal.json
- Source file: `registry/textlab/stat-reveal/stat-reveal.tsx`
- Dependencies: motion
- Variants: count, spring, scramble
- Keywords: stat, count up, counter, metric, kpi, number reveal

## When to use

- Stats bands and impact numbers on marketing pages.
- Any number shown once that deserves a moment of arrival.

## When not to use

- Numbers that keep changing; use rolling-number.
- Several dozen stats on one screen.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/stat-reveal
pnpm dlx shadcn@latest add @textlab/stat-reveal
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/stat-reveal.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { StatReveal } from "@/components/textlab/stat-reveal/stat-reveal";

export function Example() {
  return <StatReveal value={12400} suffix="+" />;
}
```

## API reference

### StatReveal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The final number. |
| `variant` | `"count" \| "spring" \| "scramble"` | `"count"` | Ease up, overshoot and settle, or shuffle digits then lock in. |
| `from` | `number` | `0` | Where the count starts. |
| `prefix` | `string` | – | Text before the number. |
| `suffix` | `string` | – | Text after the number. |
| `format` | `Intl.NumberFormatOptions` | – | Number formatting. |
| `locale` | `string` | `"en-US"` | Formatting locale. |
| `trigger` | `"mount" \| "inView" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `once` | `boolean` | `true` | In view: play once instead of on every entry. |
| `delay` | `number` | `0` | Seconds before the first part moves. |
| `duration` | `number` | `1.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
| `onComplete` | `() => void` | – | Called when a one-shot animation finishes. |

## Accessibility

- Screen readers read the final value, never the in-between numbers.
- The final width is reserved, so nothing shifts while counting.

## Motion

- Counts with the emphasized ease: fast early, slow landing.
- Starts when 45% of the stat is visible.

## Reduced motion

Shows the final value immediately.

## Performance

- One motion value; text updates outside React renders for count and spring.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: count, spring, scramble.

## Related

- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.

## Also in numbers

- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.

## Source

### components/textlab/stat-reveal/stat-reveal.tsx

```tsx
"use client";

import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { SrText } from "../lib/a11y";
import { formatNumber } from "../lib/format";
import { resolveEase, type EaseInput } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";

export type StatRevealVariant = "count" | "spring" | "scramble";

export interface StatRevealProps {
  value: number;
  /** count eases up, spring overshoots a touch and settles, scramble shuffles digits then locks in. */
  variant?: StatRevealVariant;
  /** Number to count from. */
  from?: number;
  /** Text before and after the number, for example "$" and "+". */
  prefix?: string;
  suffix?: string;
  format?: Intl.NumberFormatOptions;
  locale?: string;
  trigger?: Exclude<Trigger, "always" | "hover">;
  active?: boolean;
  once?: boolean;
  delay?: number;
  duration?: number;
  ease?: EaseInput;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
  onComplete?: () => void;
}

/**
 * A headline stat that counts up when it scrolls into view. The final value reserves its width
 * from the first frame, so the line never grows as the digits climb.
 */
export function StatReveal({
  value,
  variant = "count",
  from = 0,
  prefix = "",
  suffix = "",
  format,
  locale,
  trigger = "inView",
  active,
  once = true,
  delay = 0,
  duration = 1.8,
  ease = "emphasized",
  as: Tag = "span",
  reducedMotion = "user",
  className,
  style,
  onComplete,
}: StatRevealProps) {
  const { ref, on } = useTrigger<HTMLElement>({ trigger, active, once });
  const reduced = useReduced(reducedMotion);
  const mv = useMotionValue(from);
  const decimals = format?.maximumFractionDigits ?? (Number.isInteger(value) ? 0 : 1);
  const display = useTransform(mv, (v) => formatNumber(Number(v.toFixed(decimals)), locale, format));
  const final = formatNumber(value, locale, format);
  const [scrambled, setScrambled] = useState<string | null>(null);

  useEffect(() => {
    if (reduced) return;
    if (!on) {
      mv.set(from);
      return;
    }
    if (variant === "scramble") {
      // Shuffle every digit, then lock them in left to right.
      const start = performance.now() + delay * 1000;
      let raf = 0;
      const frame = (t: number) => {
        const p = Math.min(1, Math.max(0, (t - start) / (duration * 1000)));
        const locked = Math.floor(p * final.length);
        setScrambled(
          Array.from(final, (ch, i) => (i < locked || !/\d/.test(ch) ? ch : String(Math.floor(Math.random() * 10)))).join(""),
        );
        if (p < 1) raf = requestAnimationFrame(frame);
        else {
          setScrambled(null);
          onComplete?.();
        }
      };
      raf = requestAnimationFrame(frame);
      return () => cancelAnimationFrame(raf);
    }
    const controls = animate(mv, value, variant === "spring" ? { type: "spring", visualDuration: duration * 0.6, bounce: 0.25, delay } : { duration, delay, ease: resolveEase(ease) });
    controls.then(() => onComplete?.());
    return () => controls.stop();
    // onComplete is a callback; re-running on its identity would restart the count.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [on, value, from, variant, duration, delay, ease, reduced, final, mv]);

  if (reduced) {
    return (
      <Tag ref={ref} className={className} style={{ fontVariantNumeric: "tabular-nums", ...style }}>
        {prefix}
        {final}
        {suffix}
      </Tag>
    );
  }

  return (
    <Tag ref={ref} className={className} style={{ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", ...style }}>
      <SrText>{`${prefix}${final}${suffix}`}</SrText>
      <span aria-hidden="true" style={{ position: "relative", display: "inline-block" }}>
        <span style={{ visibility: "hidden" }}>
          {prefix}
          {final}
          {suffix}
        </span>
        <span style={{ position: "absolute", inset: 0, textAlign: "right" }}>
          {prefix}
          {variant === "scramble" ? <span>{scrambled ?? (on ? final : final.replace(/\d/g, "0"))}</span> : <motion.span>{display}</motion.span>}
          {suffix}
        </span>
      </span>
    </Tag>
  );
}

export default StatReveal;
```

Full library index: https://textlab.dev/llms.txt
