# Rolling number

> Each digit rolls to its new value.

- Type: Component (numbers)
- Page: https://textlab.dev/components/rolling-number
- Markdown: https://textlab.dev/components/rolling-number.md
- Registry item: https://textlab.dev/r/rolling-number.json
- Source file: `registry/textlab/rolling-number/rolling-number.tsx`
- Dependencies: motion
- Variants: shortest, up, down
- Keywords: number, counter, digits, roll, slot, animated number, ticker

## When to use

- Counters, balances and scores that change while people watch.
- Dashboards where the change itself is information.

## When not to use

- Numbers that never change after load; use stat-reveal.
- Values updating many times a second; the motion becomes noise.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/rolling-number
pnpm dlx shadcn@latest add @textlab/rolling-number
```

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/rolling-number.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 { RollingNumber } from "@/components/textlab/rolling-number/rolling-number";

export function Example() {
  return <RollingNumber value={count} format={{ style: "currency", currency: "USD" }} />;
}
```

## API reference

### RollingNumber

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The number to show. |
| `variant` | `"shortest" \| "up" \| "down"` | `"shortest"` | shortest takes the nearer way round; up and down always roll one way. |
| `format` | `Intl.NumberFormatOptions` | – | Formatting, for example currency or compact notation. |
| `locale` | `string` | `"en-US"` | Fixed by default so server and client render the same string. |
| `duration` | `number` | `0.6` | Seconds a digit takes to settle. |
| `bounce` | `number` | `0.1` | Spring bounce, 0 to 0.5. |
| `live` | `boolean` | `false` | Announce changes politely to screen readers. |
| `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. |

## Accessibility

- Screen readers read the formatted value, not the reels.
- Set live to announce updates; leave it off for values that tick constantly.

## Motion

- Each reel runs on a spring, so rapid updates interrupt smoothly.
- Columns are keyed from the right; new leading digits slide in and old ones slide out.

## Reduced motion

The new value replaces the old one with no rolling.

## Performance

- One spring per digit. Reels re-center silently after each roll so the DOM stays fixed size.

## 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: shortest, up, down.

## Related

- [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.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.

## 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/rolling-number/rolling-number.tsx

```tsx
"use client";

import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion, useSpring, useTransform } from "motion/react";
import { SrText } from "../lib/a11y";
import { formatNumber, isDigit } from "../lib/format";
import { spring as springs } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type RollingNumberVariant = "shortest" | "up" | "down";

export interface RollingNumberProps {
  value: number;
  /** shortest takes the nearer way round; up always rolls upward; down always rolls downward. */
  variant?: RollingNumberVariant;
  /** Intl.NumberFormat options, for example { style: "currency", currency: "EUR" }. */
  format?: Intl.NumberFormatOptions;
  /** Defaults to en-US so server and client agree. */
  locale?: string;
  /** Seconds a digit takes to settle. */
  duration?: number;
  /** Roll bounce, 0 to 0.5. */
  bounce?: number;
  /** Announce changes to screen readers. */
  live?: boolean;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

// Three cycles of digits let a column keep rolling the same way, then quietly re-center.
const CELLS = Array.from({ length: 30 }, (_, i) => i % 10);

function Digit({ digit, variant, duration, bounce }: { digit: number; variant: RollingNumberVariant; duration: number; bounce: number }) {
  const pos = useRef(10 + digit);
  const spring = useSpring(10 + digit, { visualDuration: duration, bounce });
  const y = useTransform(spring, (v) => `${-v * 10 / 3}%`);

  useEffect(() => {
    const current = ((Math.round(pos.current) % 10) + 10) % 10;
    if (current === digit) return;
    const upSteps = (digit - current + 10) % 10;
    const downSteps = upSteps - 10;
    const step = variant === "up" ? upSteps : variant === "down" ? downSteps : Math.abs(downSteps) < upSteps ? downSteps : upSteps;
    // Re-center into the middle cycle before rolling, so the column never runs out of digits.
    const base = 10 + current;
    if (Math.round(pos.current) !== base) spring.jump(base + (spring.get() - Math.round(pos.current)));
    pos.current = base + step;
    spring.set(pos.current);
  }, [digit, variant, spring]);

  return (
    <span style={{ position: "relative", display: "inline-block", height: "1.15em", overflow: "hidden", verticalAlign: "bottom" }}>
      <span style={{ visibility: "hidden" }}>0</span>
      <motion.span style={{ position: "absolute", left: 0, right: 0, top: 0, display: "flex", flexDirection: "column", y }}>
        {CELLS.map((n, i) => (
          <span key={i} style={{ height: "1.15em", lineHeight: "1.15em", textAlign: "center" }}>
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

/**
 * Each digit rolls to its new value on its own reel. Columns are keyed from the right, so the
 * ones digit stays the ones digit when the number grows, and new leading digits slide in.
 */
export function RollingNumber({
  value,
  variant = "shortest",
  format,
  locale,
  duration = 0.6,
  bounce = springs.smooth.bounce,
  live = false,
  as: Tag = "span",
  reducedMotion = "user",
  className,
  style,
}: RollingNumberProps) {
  const reduced = useReduced(reducedMotion);
  const text = formatNumber(value, locale, format);
  const chars = Array.from(text);

  if (reduced) {
    return (
      <Tag className={className} style={{ fontVariantNumeric: "tabular-nums", ...style }} aria-live={live ? "polite" : undefined}>
        {text}
      </Tag>
    );
  }

  return (
    <Tag className={className} style={{ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", ...style }}>
      <SrText>
        <span aria-live={live ? "polite" : undefined}>{text}</span>
      </SrText>
      <span aria-hidden="true" style={{ display: "inline-flex", alignItems: "flex-end", lineHeight: "1.15em" }}>
        <AnimatePresence initial={false} mode="popLayout">
          {chars.map((char, i) => {
            const key = chars.length - i;
            return (
              <motion.span
                key={key}
                layout="position"
                initial={{ opacity: 0, y: "0.4em", filter: "blur(2px)" }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={{ opacity: 0, y: "-0.4em", filter: "blur(2px)" }}
                transition={{ type: "spring", visualDuration: duration * 0.8, bounce: 0 }}
                style={{ display: "inline-block" }}
              >
                {isDigit(char) ? <Digit digit={Number(char)} variant={variant} duration={duration} bounce={bounce} /> : char}
              </motion.span>
            );
          })}
        </AnimatePresence>
      </span>
    </Tag>
  );
}

export default RollingNumber;
```

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