Display
Ticker
A number that rolls to its new value digit by digit, like an odometer, whenever it changes.
- Built on
Intl.NumberFormat@starting-styleinterpolate-size- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.8 kB
- Dependencies
- React
Live visitorsLive visitors: 1,284
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Ticker } from "@/components/equip/ticker";
export default function Demo() {
const [visitors, setVisitors] = useState(1284);
return (
<div style={{ display: "grid", justifyItems: "center", gap: 6 }}>
<span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>Live visitors</span>
<Ticker
value={visitors}
label="Live visitors"
style={{ fontSize: "calc(var(--eq-text-l) * 2)", fontWeight: "var(--eq-weight-strong)" }}
/>
<div style={{ display: "flex", gap: 6 }}>
<Button size="s" variant="outline" onClick={() => setVisitors((v) => Math.max(0, v - 3))}>
3 left
</Button>
<Button size="s" variant="outline" onClick={() => setVisitors((v) => v + 7)}>
7 joined
</Button>
</div>
</div>
);
}Examples
Currency, gaining and losing a place
The symbol and separators stay still; a new thousands place grows in and an old one folds away without the number jumping.
Cart total: 986,50 €
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Ticker } from "@/components/equip/ticker";
export default function Example() {
const [total, setTotal] = useState(986.5);
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<Ticker
value={total}
label="Cart total"
format={{ style: "currency", currency: "EUR" }}
locale="de-DE"
style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)", minWidth: 110 }}
/>
<Button size="s" variant="outline" onClick={() => setTotal((t) => t + 49.9)}>
Add tripod
</Button>
<Button size="s" variant="outline" onClick={() => setTotal((t) => Math.max(0, t - 49.9))}>
Remove tripod
</Button>
</div>
);
}Percent with a fixed fraction
Each place rolls in the direction of the change, like an odometer: 99.9 to 100.0 carries through every place.
Uptime, 30 daysUptime, 30 days: 99.9%
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Ticker } from "@/components/equip/ticker";
export default function Example() {
const [uptime, setUptime] = useState(0.999);
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Uptime, 30 days</span>
<Ticker
value={uptime}
label="Uptime, 30 days"
format={{ style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 }}
style={{ fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)", fontSize: "var(--eq-text-l)" }}
/>
<Button size="s" variant="outline" onClick={() => setUptime((u) => (u >= 1 ? 0.987 : u === 0.987 ? 0.999 : 1))}>
Next report
</Button>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
value | number | none | The number to show. |
format | Intl.NumberFormatOptions | none | Currency, percent, compact notation, fraction digits and the rest of Intl.NumberFormat. |
locale | string | "en-US" | Locale for formatting. |
label | string | none | Read before the value by screen readers, such as Live visitors. |
...rest | ComponentProps<"span"> | none | Every native span attribute. |
Anatomy
| root | .eq-ticker | |
|---|---|---|
| spoken value | .eq-ticker-sr | Visually hidden, polite live region with the plain formatted value. |
| digits | .eq-ticker-digits | The visible number, hidden from assistive tech. |
| cell | .eq-ticker-cell | One place: a rolling digit or a still separator or symbol. |
| strip | .eq-ticker-strip | The column of digits that slides inside a cell. |
| face | .eq-ticker-face | One digit on the strip. |
Notes
- Digits roll up when the value rises and down when it falls, carrying through every place like an odometer; separators and symbols stay still.
- Places are matched by place value, so gaining or losing a thousands digit grows or folds that one place without shifting the rest.
- The first render shows the value at rest, and a change mid-roll carries on from where each digit is.
- Screen readers hear the plain formatted value through a polite live region, so a value that changes many times a second will be announced often: throttle it first.
- Only the digits 0 to 9 roll; numbering systems with other digits swap in place.
- With motion off the number simply changes.
- Separators and symbols that appear or disappear grow and fold with interpolate-size, in Chromium today; elsewhere they appear and disappear at once while digits still roll.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/ticker.tsx150 lines
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react";
import "./ticker.css";
export type TickerProps = Omit<ComponentProps<"span">, "children"> & {
value: number;
/** Passed to Intl.NumberFormat: currency, percent, compact notation, fraction digits. */
format?: Intl.NumberFormatOptions;
locale?: string;
/** Read before the value by screen readers, e.g. "Live visitors". */
label?: string;
};
type Cell = { key: string; char: string; digit: number | null };
type Leaving = { cell: Cell; before: string | null };
/* Each strip holds 0 to 9 three times; a digit rests in the middle band so it can roll either way. */
const STRIP = "012345678901234567890123456789".split("");
function toCells(value: number, locale: string, format?: Intl.NumberFormatOptions): Cell[] {
const parts = new Intl.NumberFormat(locale, format).formatToParts(value);
let place = parts.filter((p) => p.type === "integer").reduce((n, p) => n + [...p.value].length, 0);
let fraction = 0;
const seen: Record<string, number> = {};
const cells: Cell[] = [];
for (const part of parts) {
const occurrence = (seen[part.type] = (seen[part.type] ?? 0) + 1);
[...part.value].forEach((char, index) => {
const digit = /^[0-9]$/.test(char) ? Number(char) : null;
/* Digits are keyed by place value, so units stay units when the number grows. */
if (part.type === "integer") cells.push({ key: `i${--place}`, char, digit });
else if (part.type === "fraction") cells.push({ key: `f${fraction++}`, char, digit });
else if (part.type === "group") cells.push({ key: `g${place}`, char, digit: null });
else cells.push({ key: `${part.type}${occurrence}.${index}`, char, digit: null });
});
}
return cells;
}
export function Ticker({ value, format, locale = "en-US", label, className, ...rest }: TickerProps) {
const cells = toCells(value, locale, format);
const text = cells.map((c) => c.char).join("");
const [ready, setReady] = useState(false);
const [leaving, setLeaving] = useState<Leaving[]>([]);
const rootRef = useRef<HTMLSpanElement>(null);
const last = useRef<{ value: number; text: string; cells: Cell[] } | null>(null);
useEffect(() => setReady(true), []);
useLayoutEffect(() => {
const previous = last.current;
last.current = { value, text, cells };
const root = rootRef.current;
if (!previous || previous.text === text || !root) return;
const sample = root.querySelector<HTMLElement>(".eq-ticker-cell");
const moving = sample ? parseFloat(getComputedStyle(sample).transitionDuration) > 0 : false;
const up = value >= previous.value;
for (const cell of cells) {
if (cell.digit === null) continue;
const strip = root.querySelector<HTMLElement>(`[data-key="${cell.key}"] > .eq-ticker-strip`);
const old = previous.cells.find((c) => c.key === cell.key);
if (!strip || !old || old.digit === null || old.digit === cell.digit || !moving) continue;
const row = strip.parentElement ? parseFloat(getComputedStyle(strip.parentElement).height) : 0;
if (!row) continue;
/* Start from wherever the strip is now, so a change mid-roll carries on instead of snapping. */
const shift = getComputedStyle(strip).translate.split(" ")[1];
const now = shift ? -parseFloat(shift) / row : 10 + old.digit;
const target = 10 + cell.digit;
const gap = up ? (((target - now) % 10) + 10) % 10 : (((now - target) % 10) + 10) % 10;
if (gap < 0.05 || gap > 9.95) continue;
const start = up ? target - gap : target + gap;
strip.style.transition = "none";
strip.style.setProperty("--_i", String(start));
void strip.offsetHeight;
strip.style.transition = "";
strip.style.setProperty("--_i", String(target));
}
const keys = new Set(cells.map((c) => c.key));
const gone: Leaving[] = [];
if (moving) {
previous.cells.forEach((cell, index) => {
if (keys.has(cell.key)) return;
const next = previous.cells.slice(index + 1).find((c) => keys.has(c.key));
gone.push({ cell, before: next?.key ?? null });
});
}
setLeaving((list) => (list.length === 0 && gone.length === 0 ? list : gone));
// Runs once per new formatted value; cells and value are derived from the same render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
const keys = new Set(cells.map((c) => c.key));
const stale = leaving.filter((l) => !keys.has(l.cell.key));
const drop = (key: string) => setLeaving((list) => list.filter((l) => l.cell.key !== key));
const renderLeaving = (before: string | null) =>
stale
.filter((l) => l.before === before)
.map(({ cell }) => (
<span
key={`out-${cell.key}`}
className="eq-ticker-cell"
data-leaving=""
data-kind={cell.digit === null ? "literal" : "digit"}
onTransitionEnd={(event) => {
if (event.propertyName === "width") drop(cell.key);
}}
>
{cell.char}
</span>
));
return (
<span
{...rest}
ref={rootRef}
className={["eq-ticker", className].filter(Boolean).join(" ")}
data-ready={ready ? "" : undefined}
>
<span className="eq-ticker-sr" aria-live="polite" aria-atomic="true">
{label ? `${label}: ${text}` : text}
</span>
<span className="eq-ticker-digits" aria-hidden="true">
{cells.flatMap((cell) => [
...renderLeaving(cell.key),
cell.digit === null ? (
<span key={cell.key} className="eq-ticker-cell" data-key={cell.key} data-kind="literal">
{cell.char}
</span>
) : (
<span key={cell.key} className="eq-ticker-cell" data-key={cell.key} data-kind="digit">
<span className="eq-ticker-strip" style={{ "--_i": 10 + cell.digit } as CSSProperties}>
{STRIP.map((d, i) => (
<span key={i} className="eq-ticker-face">
{d}
</span>
))}
</span>
</span>
),
])}
{renderLeaving(null)}
</span>
</span>
);
}components/equip/ticker.css83 lines
@layer equip {
.eq-ticker {
--_h: 1.2em;
display: inline-block;
font-variant-numeric: tabular-nums;
line-height: var(--_h);
white-space: nowrap;
interpolate-size: allow-keywords;
}
.eq-ticker-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-ticker-digits {
display: inline-flex;
vertical-align: bottom;
}
.eq-ticker-cell {
display: inline-block;
height: var(--_h);
overflow: hidden;
transition:
width var(--eq-dur-move) var(--eq-spring),
opacity var(--eq-dur) var(--eq-ease);
}
.eq-ticker-cell[data-kind="digit"] {
width: 1ch;
text-align: center;
}
.eq-ticker-cell[data-kind="literal"] {
width: auto;
}
/* New places grow in once the ticker is live; nothing animates on first paint. */
@starting-style {
.eq-ticker[data-ready] .eq-ticker-cell {
width: 0;
opacity: 0;
}
}
.eq-ticker-cell[data-leaving] {
width: 0;
opacity: 0;
}
@starting-style {
.eq-ticker-cell[data-leaving][data-kind="digit"] {
width: 1ch;
opacity: 1;
}
.eq-ticker-cell[data-leaving][data-kind="literal"] {
width: auto;
opacity: 1;
}
}
.eq-ticker-strip {
display: flex;
flex-direction: column;
translate: 0 calc(var(--_i) * var(--_h) * -1);
transition: translate var(--eq-dur-move) var(--eq-spring);
}
.eq-ticker-face {
display: block;
height: var(--_h);
}
}