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.
Live preview
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-rollUsage
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | - | The number to show. Changing it starts the roll. |
| format | Intl.NumberFormatOptions | undefined | Formatting, for example { style: "currency", currency: "USD" }. |
| locales | string | string[] | "en-US" | Locale for formatting. |
| prefix | string | "" | Text before the number. |
| suffix | string | "" | Text after the number. |
| from | number | undefined | Start value; the number rolls from here to value once it is shown. |
| direction | "up" | "down" | undefined | Force the roll direction. By default it follows the change. |
| duration | number | 900 | Roll 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| NR1 | Value goes up | digit strip | translate y | old digit → new digit, rolls up (9 goes on to 0) | duration-6xl (900)ease-out | Swaps at once |
| NR2 | Value goes down | digit strip | translate y | old digit → new digit, rolls down (0 goes on to 9) | duration-6xl (900)ease-out | Swaps at once |
| NR3 | A place is added | new column | max width, opacity | 0, 0 → full width, 1 | duration-4xl (520)ease-out | Shows at once |
| NR4 | A place is removed | old column | max width, opacity | full width, 1 → 0, 0 | duration-xl (380)ease-out | Hides at once |
| NR5 | Roll ends | digit strip | translate y | end position → same digit in the middle set (looks the same) | 0ease-lineardelay duration-6xl (900) | Stays still |
| NR6 | First shown with a from value | all digits | translate y | from value → value | duration-6xl (900)ease-outdelay 16ms | Swaps 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.