Rolling Number

A number whose digits roll to their new values.

'use client';

import { useEffect, useState } from 'react';
import { RollingNumber } from '@/components/panelui/rolling-number';

/** Jobs waiting in a queue, read every couple of seconds. */
const WAITING = [312, 298, 305, 341, 327, 289];

export default function RollingNumberQueue() {
  const [reading, setReading] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setReading((current) => (current + 1) % WAITING.length);
    }, 2200);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="flex flex-col gap-1">
      <span className="text-sm font-medium text-muted-foreground">Jobs waiting</span>
      <RollingNumber value={WAITING[reading]} className="text-6xl font-bold tracking-tight" />
    </div>
  );
}

Use it for a figure that changes while someone is looking at it: a live count, a balance, a score. Each digit rolls to its new value on its own, so going from 312 to 318 moves one column and leaves the other two where they were.

The number is written by Intl.NumberFormat. Currency, percent and compact notation are options you pass, not strings you build.

For a number that is shown once and never changes, plain text does the same job.

Installation

npx shadcn@latest add https://panelui.dev/r/web/rolling-number.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/rolling-number instead of @/components/panelui/rolling-number. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install tailwind-variants

Copy the source into components/panelui/rolling-number.tsx:

Usage

import { RollingNumber } from "@/components/panelui/rolling-number"
<RollingNumber value={waiting} />

Examples

Currency

format takes what Intl.NumberFormat takes. The currency sign and the separators stay where they are while the digits roll.

'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { RollingNumber } from '@/components/panelui/rolling-number';

export default function RollingNumberCurrency() {
  const [total, setTotal] = useState(1864.5);

  return (
    <div className="flex flex-col items-center gap-6">
      <div className="flex flex-col items-center gap-1">
        <span className="text-sm font-medium text-muted-foreground">Trip budget left</span>
        <RollingNumber
          value={total}
          format={{ style: 'currency', currency: 'EUR' }}
          className="text-4xl font-bold tracking-tight"
        />
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        <Button variant="outline" size="sm" onClick={() => setTotal((value) => value - 72.4)}>
          Book a train
        </Button>
        <Button size="sm" onClick={() => setTotal((value) => value + 350)}>
          Add a refund
        </Button>
      </div>
    </div>
  );
}

Percent and compact

Three figures in three notations, changed together. Compact notation keeps a large number short: 46,200 is written $46.2K.

'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { RollingNumber } from '@/components/panelui/rolling-number';

/** Three weeks after a launch. */
const WEEKS = [
  { downloads: 18420, paid: 0.031, sales: 46200 },
  { downloads: 21960, paid: 0.038, sales: 61850 },
  { downloads: 19310, paid: 0.027, sales: 39400 },
];

export default function RollingNumberFormats() {
  const [week, setWeek] = useState(0);
  const { downloads, paid, sales } = WEEKS[week];

  return (
    <div className="flex flex-col items-center gap-6">
      <dl className="grid grid-cols-3 gap-8 text-center">
        <div className="flex flex-col gap-1">
          <dt className="text-sm font-medium text-muted-foreground">Downloads</dt>
          <dd className="text-2xl font-bold">
            <RollingNumber value={downloads} />
          </dd>
        </div>
        <div className="flex flex-col gap-1">
          <dt className="text-sm font-medium text-muted-foreground">Trial to paid</dt>
          <dd className="text-2xl font-bold">
            <RollingNumber
              value={paid}
              format={{ style: 'percent', minimumFractionDigits: 1 }}
            />
          </dd>
        </div>
        <div className="flex flex-col gap-1">
          <dt className="text-sm font-medium text-muted-foreground">Sales</dt>
          <dd className="text-2xl font-bold">
            <RollingNumber
              value={sales}
              format={{
                style: 'currency',
                currency: 'USD',
                notation: 'compact',
                maximumFractionDigits: 1,
              }}
            />
          </dd>
        </div>
      </dl>
      <Button
        variant="outline"
        size="sm"
        onClick={() => setWeek((current) => (current + 1) % WEEKS.length)}
      >
        Next week
      </Button>
    </div>
  );
}

A slower roll

duration is how long one digit takes to arrive, in milliseconds. It is 600 unless you set it.

'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { RollingNumber } from '@/components/panelui/rolling-number';

export default function RollingNumberSlow() {
  const [score, setScore] = useState(2048);

  return (
    <div className="flex flex-col items-center gap-6">
      <RollingNumber
        value={score}
        duration={1400}
        className="text-5xl font-semibold tracking-tight"
      />
      <Button variant="outline" size="sm" onClick={() => setScore((value) => value * 2)}>
        Double it
      </Button>
    </div>
  );
}

API Reference

RollingNumber

PropTypeDefaultDescription
valuenumber—The number to show. Each digit rolls to its new value on its own.
formatIntl.NumberFormatOptions—How the number is written: currency, percent, compact notation, how many decimals. Passed to Intl.NumberFormat.
localesIntl.LocalesArgument'en-US'The locale the number is written in. Defaults to en-US rather than the browser's, so a page rendered on a server writes the same thing the browser then does.
durationnumber600How long a digit takes to roll, in milliseconds.

Every part also takes the props of the element it renders, and a className.

Notes

Width

The digits are set in tabular figures, so a 1 is as wide as an 8 and the number keeps its width while it rolls. It does get wider when it gains a digit, as 99 does on its way to 100. Give it room, or a fixed width, where something sits beside it.

Server rendering

locales defaults to en-US, not to the browser's locale. A page rendered on a server would otherwise write the number one way and the browser another, and React reports that as a hydration mismatch. Pass the locale you want written.

Reduced motion

With the system's reduce-motion setting on, a digit changes without rolling.

Screen readers

A screen reader is given the formatted number once, as text. The rolling columns are hidden from it, so it does not read ten digits per column.

On this page