# Number Ticker

An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.

Source: https://ui.fewtone.com/components/number-ticker

## Installation

```bash
npx shadcn@latest add https://ui.fewtone.com/r/number-ticker.json
```

## Usage

```tsx
import { NumberTicker } from "@/components/ui/number-ticker"

export function Users() {
  return <NumberTicker value={12480} className="text-4xl font-semibold" />
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | – | The number to show. |
| `from` | `number` | `0` | Where the first roll starts. |
| `format` | `Intl.NumberFormatOptions` | – | How to write it, for example { style: "currency", currency: "USD" } or { style: "percent" }. |
| `locale` | `string` | `"en-US"` | The locale to format in. Fixed by default so the server and the browser write the same text. |
| `prefix` | `string` | – | Text before the number, part of what flows. |
| `suffix` | `string` | – | Text after the number, part of what flows. |
| `continuous` | `boolean` | `true` | When a higher place changes, the places below spin a whole turn, so a jump reads as counting. Off, each digit takes the short way. |
| `direction` | `"up" \| "down"` | – | Which way the digits turn. Left out, up when the number grows and down when it shrinks; set it to always turn one way, like an odometer. |
| `delay` | `number` | `0` | Seconds to wait before the first roll. |
| `startOnView` | `boolean` | `true` | Roll once it's half in view, once. With false, it rolls as soon as it mounts. |

**When to use it:** Use NumberTicker for numbers that should draw the eye as they arrive: stats on a landing page, a dashboard total, a live count. It renders the final value on the server and rolls when scrolled into view. For a number the user edits, use NumberInput; for how far a task is, use Progress.
**Built on:** Web Animations API, CSS registered properties, Intl.NumberFormat.
**npm packages (installed by the CLI):** motion@^14.0.0.

## Examples

### A changing value

Step it or shuffle to a number of another length. Every change flows on one soft spring: the digits roll, the characters that stay glide to their new places as the width follows, new ones come in through the blur and the ones that go leave through it. Switch the format and it flows to that too.

```tsx
"use client";

import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { MinusIcon, PlusIcon, ShuffleIcon } from "lucide-react";
import { useId, useState } from "react";

/** Values of different lengths, so Shuffle shows places coming and going and the width gliding */
const VALUES = [1248, 98.5, 24501.75, 7, 312480, 3.2];
const formats = {
  currency: { style: "currency", currency: "USD" },
  plain: { maximumFractionDigits: 2 },
} as const satisfies Record<string, Intl.NumberFormatOptions>;
type Format = keyof typeof formats;

/*
 * One big number that changes, the way a price or a balance does: step it down or up, or shuffle to a value of
 * another length. Each change flows from what's on screen: the digits roll, the characters that stay glide to
 * their new places, new ones come in through the blur and the ones that go leave through it. Format in the
 * top-right corner. An empty row at the bottom, as tall as the setting's row, keeps the number centred.
 */
export default function NumberTickerFlow() {
  const id = useId();
  const [index, setIndex] = useState(0);
  const [value, setValue] = useState(VALUES[0] ?? 0);
  const [format, setFormat] = useState<Format>("currency");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-format`} className="text-sm text-muted-foreground">
          Format
        </label>
        <Select value={format} onValueChange={(next) => setFormat(next as Format)}>
          <SelectTrigger id={`${id}-format`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="currency">Currency</SelectItem>
            <SelectItem value="plain">Plain</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-col items-center justify-center gap-6">
        <NumberTicker
          value={value}
          from={VALUES[0]}
          format={formats[format]}
          locale="en-US"
          className="text-5xl font-semibold tracking-tight sm:text-6xl"
        />
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="icon"
            aria-label="Decrease by 100"
            onClick={() => setValue((current) => Math.max(0, Math.round((current - 100) * 100) / 100))}
          >
            <MinusIcon aria-hidden />
          </Button>
          <Button
            variant="outline"
            onClick={() => {
              const next = (index + 1) % VALUES.length;
              setIndex(next);
              setValue(VALUES[next] ?? 0);
            }}
          >
            <ShuffleIcon aria-hidden />
            Shuffle
          </Button>
          <Button
            variant="outline"
            size="icon"
            aria-label="Increase by 100"
            onClick={() => setValue((current) => Math.round((current + 100) * 100) / 100)}
          >
            <PlusIcon aria-hidden />
          </Button>
        </div>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}
```

### Stats on view

Each stat rolls up from 0 once it's half in view. Places 0 doesn't have, and the separators and signs beside them, come in as they roll, so the number grows leftwards. Press Replay to roll again.

```tsx
import { NumberTicker } from "@/components/ui/number-ticker";

const stats: { label: string; value: number; format?: Intl.NumberFormatOptions }[] = [
  { label: "Users", value: 12480 },
  {
    label: "Revenue",
    value: 84320,
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 },
  },
  { label: "Uptime", value: 0.9998, format: { style: "percent", minimumFractionDigits: 2 } },
];

/*
 * A count, a currency and a percentage rolling to their values, named underneath, large enough to see the
 * reels spin and lock in from the left. Each starts from 0, so the places 0 doesn't have fade in as they roll.
 * Replay rolls them again.
 */
export default function NumberTickerStats() {
  return (
    <ul className="flex flex-wrap justify-center gap-x-12 gap-y-8">
      {stats.map((stat) => (
        <li key={stat.label} className="flex flex-col items-center gap-1">
          <NumberTicker
            value={stat.value}
            format={stat.format}
            locale="en-US"
            className="text-5xl font-semibold tracking-tight"
          />
          <span className="text-xs text-muted-foreground">{stat.label}</span>
        </li>
      ))}
    </ul>
  );
}
```

## Related

- [Progress](https://ui.fewtone.com/components/progress.md): An accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.
- [Number Input](https://ui.fewtone.com/components/number-input.md): An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.
- [Text Effects](https://ui.fewtone.com/components/text-effects.md): Accessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.
