Lunia DS

Text and numbers

Number roll

A number that changes like an odometer: each digit rolls in the direction the value moved, so up and down are clear at a glance.

Loading preview

A price that changes between $1,240 and $1,380, plus a counter that rolls up from 0 when shown. Replay sets the start value again.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add number-roll

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Button } from '../button/button';
import { NumberRoll } from './number-roll';

const PRICES = [1240, 1380] as const;
const USD: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 };

export default function NumberRollDemo({ replay = 0 }: { replay?: number }) {
  const [high, setHigh] = React.useState(false);

  React.useEffect(() => {
    if (replay === 0) return;
    setHigh(false);
    const timer = window.setTimeout(() => setHigh(true), 300);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex items-start gap-12">
        <div className="flex flex-col gap-1">
          <p className="text-sm text-tertiary">Monthly cost</p>
          <NumberRoll
            data-testid="price"
            className="text-display-sm font-semibold text-primary"
            value={PRICES[high ? 1 : 0]}
            format={USD}
            locales="en-US"
          />
        </div>
        <div className="flex flex-col gap-1">
          <p className="text-sm text-tertiary">Active seats</p>
          {/* Replay mounts the counter again, so it rolls up from 0. */}
          <NumberRoll
            key={replay}
            data-testid="counter"
            className="text-display-sm font-semibold text-primary"
            value={128}
            from={0}
          />
        </div>
      </div>
      <Button size="sm" hierarchy="secondary" onClick={() => setHigh((h) => !h)}>
        Change price
      </Button>
    </div>
  );
}

API

Parts: root, place column, digit strip (0 to 9, repeated), separator characters, screen reader text.

PropTypeDefaultDescription
valuenumber-The number to show. Changing it starts the roll.
formatIntl.NumberFormatOptionsundefinedFormatting, for example { style: "currency", currency: "USD" }.
localesstring | string[]"en-US"Locale for formatting.
prefixstring""Text before the number.
suffixstring""Text after the number.
fromnumberundefinedStart value; the number rolls from here to value once it is shown.
direction"up" | "down"undefinedForce the roll direction. By default it follows the change.
durationnumber900Roll time in ms. Defaults to duration-6xl.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
NR1Value goes updigit striptranslate yold digit → new digit, rolls up (9 goes on to 0)duration-6xl (900)ease-outSwaps at once
NR2Value goes downdigit striptranslate yold digit → new digit, rolls down (0 goes on to 9)duration-6xl (900)ease-outSwaps at once
NR3A place is addednew columnmax width, opacity0, 0 → full width, 1duration-4xl (520)ease-outShows at once
NR4A place is removedold columnmax width, opacityfull width, 1 → 0, 0duration-xl (380)ease-outHides at once
NR5Roll endsdigit striptranslate yend position → same digit in the middle set (looks the same)0ease-lineardelay duration-6xl (900)Stays still
NR6First shown with a from valueall digitstranslate yfrom value → valueduration-6xl (900)ease-outdelay 16msSwaps at once

Accessibility

  • The formatted final value is in a visually hidden element; the rolling digits are aria-hidden.
  • Uses tabular numbers so digits do not shift while rolling.
  • The component does not announce changes itself; place it in a live region when needed.