# Dither number

An animated number for KPI tiles and landing stats: tabular digits that roll or resolve through a dither, with Intl formatting, prefix and suffix, colour and size presets.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/dither-number.json](https://dithercn.dev/r/dither-number.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/dither-number.json
```

## Usage [#usage]

```tsx
import { DitherNumber } from "@/components/dithercn/dither-number";

export function Stat() {
  return <DitherNumber value={128406} size="xl" />;
}
```

When the number scrolls into view, every digit moves from `from` (0 by default) to its place in `value`. Change `value` later and only the digits that differ move, left to right, `stagger` milliseconds apart. While a digit moves it takes the `accent` colour, then fades back to the number's colour as it lands.

There are two effects:

* `roll` spins each digit on a strip, up when the value grows and down when it shrinks. The top and bottom of each digit's window dither out, so digits fade in and out of the line instead of being cut off. A place that moves by ten or more spins a full extra turn, like a mechanical counter.
* `resolve` turns the old digit into the new one cell by cell. The two use complementary dither masks, so each cell shows one digit or the other and they never blur together.

Digits are tabular, so the width only changes when the number of digits does. Separators, currency symbols, the `prefix` and the `suffix` keep their natural width and never move.

## Examples [#examples]

### KPI tiles [#kpi-tiles]

A dashboard row whose values update every few seconds. `size="sm"`, a muted `suffix` through `affixClassName`, and `resolve` and `roll` side by side. The latency tile uses an orange accent.

```tsx title="examples/dither-number-kpis.tsx"
"use client";

import { useEffect, useState } from "react";

import { DitherNumber } from "@/components/dithercn/dither-number";
import { cssColor, fillStyle } from "@/lib/dither";

interface Kpi {
  label: string;
  value: number;
  previous: number;
  format: Intl.NumberFormatOptions;
  suffix?: string;
  /** Lower is better, as for latency. */
  invert?: boolean;
  /** Share of the target reached, for the meter. */
  target: number;
}

const START: Kpi[] = [
  {
    format: { currency: "USD", maximumFractionDigits: 0, style: "currency" },
    label: "Revenue today",
    previous: 48_120,
    target: 60_000,
    value: 48_120,
  },
  {
    format: { maximumFractionDigits: 1, notation: "compact" },
    label: "Active users",
    previous: 18_420,
    target: 24_000,
    value: 18_420,
  },
  {
    format: { maximumFractionDigits: 0 },
    invert: true,
    label: "p95 latency",
    previous: 182,
    suffix: " ms",
    target: 300,
    value: 182,
  },
  {
    format: {
      maximumFractionDigits: 2,
      minimumFractionDigits: 2,
      style: "percent",
    },
    label: "Conversion",
    previous: 0.0412,
    target: 0.06,
    value: 0.0412,
  },
];

const STEPS = [740, 260, 14, 0.0011];

const tick = (kpis: Kpi[]) =>
  kpis.map((kpi, index) => {
    const step = (STEPS[index] ?? 1) * (Math.random() * 1.6 - 0.45);
    const raw = Math.max(0, kpi.value + step);
    const value =
      index === 3 ? Math.round(raw * 10_000) / 10_000 : Math.round(raw);
    return { ...kpi, previous: kpi.value, value };
  });

const Delta = ({ kpi }: { kpi: Kpi }) => {
  const change =
    kpi.previous === 0 ? 0 : (kpi.value - kpi.previous) / kpi.previous;
  const good = kpi.invert ? change <= 0 : change >= 0;
  const color = good ? "green" : "red";
  return (
    <span
      className="font-mono text-[11px] tabular-nums transition-colors duration-500"
      style={{ color: cssColor(color) }}
    >
      {change >= 0 ? "▲" : "▼"} {Math.abs(change * 100).toFixed(1)}%
    </span>
  );
};

export default function DitherNumberKpis() {
  const [kpis, setKpis] = useState(START);

  useEffect(() => {
    const timer = setInterval(() => setKpis(tick), 2400);
    return () => clearInterval(timer);
  }, []);

  return (
    <dl className="grid w-full max-w-xl grid-cols-1 gap-2 px-4 sm:grid-cols-2">
      {kpis.map((kpi, index) => (
        <div
          key={kpi.label}
          className="bg-background flex flex-col gap-3 rounded-2xl p-4"
        >
          <div className="flex items-center justify-between gap-2">
            <dt className="text-muted-foreground text-xs">{kpi.label}</dt>
            <Delta kpi={kpi} />
          </div>
          <dd>
            <DitherNumber
              value={kpi.value}
              from={START[index]?.value ?? 0}
              format={kpi.format}
              locales="en-US"
              suffix={kpi.suffix}
              affixClassName="text-muted-foreground text-base font-normal tracking-normal"
              effect={index % 2 === 0 ? "resolve" : "roll"}
              accent={index === 2 ? "orange" : "ink"}
              size="md"
            />
          </dd>
          <div
            aria-hidden="true"
            className="bg-muted relative h-1.5 overflow-hidden rounded-[2px]"
          >
            <div
              className="absolute inset-y-0 left-0 transition-[width] duration-700 ease-(--ease-out)"
              style={{
                ...fillStyle("gradient", {
                  cell: 1.5,
                  color: index === 2 ? "orange" : "ink",
                  edge: "right",
                  length: 40,
                  to: 0.25,
                }),
                width: `${Math.min(100, (kpi.value / kpi.target) * 100)}%`,
              }}
            />
          </div>
        </div>
      ))}
    </dl>
  );
}
```

### Landing stat [#landing-stat]

`size="display"` scales with the viewport. A longer `duration` and `stagger` make the first roll feel heavier; after that, the counter ticks up live and only the last few digits move.

```tsx title="examples/dither-number-landing.tsx"
"use client";

import { useEffect, useState } from "react";

import { DitherNumber } from "@/components/dithercn/dither-number";
import { fillStyle } from "@/lib/dither";

export default function DitherNumberLanding() {
  const [frames, setFrames] = useState(418_906);

  // Ticks up like a live counter once it has rolled in.
  useEffect(() => {
    const timer = setInterval(
      () => setFrames((value) => value + 120 + Math.round(Math.random() * 900)),
      3200
    );
    return () => clearInterval(timer);
  }, []);

  return (
    <section className="relative flex size-full min-h-80 flex-col items-center justify-center gap-6 overflow-hidden px-6 py-12 text-center">
      <div
        aria-hidden="true"
        className="absolute inset-0 opacity-25"
        style={fillStyle("gradient", { edge: "bottom", length: 44, to: 0 })}
      />
      <span className="text-muted-foreground relative font-mono text-xs tracking-[0.16em] uppercase">
        This week
      </span>
      <DitherNumber
        value={frames}
        locales="en-US"
        size="display"
        stagger={60}
        duration={1200}
        className="relative"
      />
      <p className="text-muted-foreground relative max-w-sm text-sm text-pretty sm:text-base">
        frames dithered by teams shipping with dithercn, counted live.
      </p>
    </section>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` |  | The number to show. Change it and the digits move to the new value. |
| `from` | `number` | `0` | Where the first run starts. |
| `format` | `Intl.NumberFormatOptions` |  | Currency, percent, compact notation, decimals and so on. |
| `locales` | `Intl.LocalesArgument` | `The visitor's locale` | Passed to Intl.NumberFormat. Set it when the server and client must format the same way. |
| `prefix` | `string` |  | Text before the number, such as "+". |
| `suffix` | `string` |  | Text after the number, such as " ms". |
| `affixClassName` | `string` |  | Classes for the prefix and suffix, for example a smaller, muted unit. |
| `effect` | `"roll" \| "resolve"` | `"roll"` | Digits spin on a strip, or turn into the new digit cell by cell. |
| `trigger` | `"mount" \| "in-view"` | `"in-view"` | Start the first run on mount, or when scrolled into view. |
| `size` | `"sm" \| "md" \| "lg" \| "xl" \| "display"` |  | A type preset: 2xl, 4xl, 6xl, 8xl, or a fluid display size. Leave it unset to size the number with your own classes. |
| `color` | `DitherColor` | `the text colour` | Colour of the number: a palette name or any CSS colour. |
| `accent` | `DitherColor \| "none"` | `"ink"` | Colour a digit takes while it moves, fading back as it lands. |
| `duration` | `number` | `900 (roll), 560 (resolve)` | Milliseconds per change, for one digit. |
| `stagger` | `number` | `40` | Milliseconds between one digit starting and the next. |
| `matrix` | `"bayer2" \| "bayer4" \| "bayer8"` | `"bayer4"` | The threshold pattern for the dithered edges and the resolve. |
| `cell` | `number` | `font size / 30` | Size of one dither cell in CSS pixels, at least 1. |

## Notes [#notes]

* The formatted value, with its prefix and suffix, is real text laid over the animation and made transparent, so it can be selected and copied and is what screen readers and search engines read. The moving digits are hidden from assistive technology. For a value that updates live, add `aria-live="polite"` yourself.
* The number sets `line-height: 1.1` so the dithered fade has room above and below the digits. A tighter `leading-*` class works, but the fade may touch the tops of tall glyphs.
* The server renders the final value. On the client the digits wait at `from` until the trigger fires.
* With reduced motion turned on, the number changes instantly.
* Only ASCII digits animate. Formats that use another numbering system show their digits without motion.