Number flow
A number whose changed digits pop in one after another out of a slight blur, from the right. Unchanged digits stay put, the row glides when the digit count changes, and the whole number tints green or red for a beat after it moves.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// NumberFlow: a number whose changed digits pop in one after another out of a slight blur, from the right. Unchanged
// digits stay put, the row glides when the digit count changes, and the whole number tints green or red for a beat
// after it moves. Out of the box it is a price that drifts on its own with down/up buttons; pass `value` to drive it.
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useReducedMotion } from "framer-motion";
import { Minus, Plus } from "lucide-react";
export type NumberFlowCue = "next" | "back";
export type NumberFlowProps = {
/** The number shown (controlled). Leave out to let the block hold it. */
value?: number;
/** Starting number when uncontrolled. */
defaultValue?: number;
/** Called with every new value: nudges and drift. */
onChange?: (value: number) => void;
/** Intl.NumberFormat options. Defaults to 2 fraction digits. */
format?: Intl.NumberFormatOptions;
locale?: string;
/** Text before the number, set smaller. */
prefix?: string;
/** Text after the number, set smaller. */
suffix?: string;
/** Tint the number by direction of the last change for a moment. */
trend?: boolean;
/** ms between each new digit popping in. */
stagger?: number;
/** Font size in px. */
size?: number;
/** Announce every change to screen readers (aria-live). Off by default: most numbers tick too often to read out. */
live?: boolean;
/** Uncontrolled only: wander on its own every `driftEvery` ms. */
drift?: boolean;
/** Show the down and up buttons. */
controls?: boolean;
/** How much the down button takes off. */
downBy?: number;
/** How much the up button adds. */
upBy?: number;
/** Accessible names of the two buttons. */
downLabel?: string;
upLabel?: string;
/** Sound hook: "next" for the up button, "back" for the down button. */
onSound?: (cue: NumberFlowCue) => void;
className?: string;
style?: CSSProperties;
};
type Cell = { key: string; ch: string; digit: boolean; fresh: boolean; i: number };
export function NumberFlow({
value,
defaultValue = 1284.5,
onChange,
format = { minimumFractionDigits: 2, maximumFractionDigits: 2 },
locale = "en-US",
prefix = "$",
suffix = "",
trend = true,
stagger = 36,
size = 44,
live = false,
drift = true,
controls = true,
downBy = 137.25,
upBy = 1000,
downLabel = "Down",
upLabel = "Up",
onSound,
className,
style,
}: NumberFlowProps) {
const [inner, setInner] = useState(defaultValue);
const v = value ?? inner;
const set = (n: number) => {
if (value === undefined) setInner(n);
onChange?.(n);
};
const setRef = useRef(set);
setRef.current = set;
const vRef = useRef(v);
vRef.current = v;
useEffect(() => {
if (!drift || value !== undefined) return;
const t = setInterval(() => setRef.current(+(vRef.current + (Math.random() - 0.48) * 40).toFixed(2)), 2200);
return () => clearInterval(t);
}, [drift, value]);
const nudge = (d: number) => {
onSound?.(d > 0 ? "next" : "back");
set(+(v + d).toFixed(2));
};
return (
<div className={className ? `nfw-root ${className}` : "nfw-root"} style={style}>
<Flow value={v} format={format} locale={locale} prefix={prefix} suffix={suffix} trend={trend} stagger={stagger} size={size} live={live} />
{controls && (
<div className="nfw-row">
<button type="button" className="nfw-btn" aria-label={downLabel} onClick={() => nudge(-downBy)}>
<Minus size={14} strokeWidth={2.2} />
</button>
<button type="button" className="nfw-btn" aria-label={upLabel} onClick={() => nudge(upBy)}>
<Plus size={14} strokeWidth={2.2} />
</button>
</div>
)}
</div>
);
}
type FlowProps = Required<Pick<NumberFlowProps, "value" | "locale" | "prefix" | "suffix" | "trend" | "stagger" | "size" | "live">> & Pick<NumberFlowProps, "format">;
function Flow({ value, format, locale, prefix, suffix, trend, stagger, size, live }: FlowProps) {
const still = useReducedMotion();
const text = new Intl.NumberFormat(locale, { maximumFractionDigits: 2, ...format }).format(value);
const prev = useRef<{ text: string; value: number }>({ text, value });
const keys = useRef<number[]>([]); // per right-index: gen in which that cell last changed
const gen = useRef(0);
const [cells, setCells] = useState<Cell[]>(() => splitCells(text, "", 0, keys.current));
const [dir, setDir] = useState<"up" | "down" | null>(null);
const tmr = useRef(0);
useEffect(() => {
const p = prev.current;
if (p.text === text) return;
const d = value > p.value ? "up" : value < p.value ? "down" : null;
prev.current = { text, value };
setCells(splitCells(text, p.text, ++gen.current, keys.current));
if (trend && d) {
setDir(d);
clearTimeout(tmr.current);
tmr.current = window.setTimeout(() => setDir(null), 900);
}
}, [text, value, trend]);
useEffect(() => () => clearTimeout(tmr.current), []);
// When the digit count changes, glide the row's width instead of letting the number re-centre with a cut.
const row = useRef<HTMLSpanElement>(null);
const w = useRef(0);
useLayoutEffect(() => {
const el = row.current;
if (!el) return;
const from = el.style.width ? el.offsetWidth : w.current;
el.style.width = "";
const to = (w.current = el.offsetWidth);
if (!from || still || Math.abs(from - to) < 1) return;
el.style.width = `${from}px`;
void el.offsetWidth; // commit the start width before transitioning
el.style.width = `${to}px`;
const done = (e: TransitionEvent) => e.propertyName === "width" && ((el.style.width = ""), el.removeEventListener("transitionend", done));
el.addEventListener("transitionend", done);
}, [cells, still]);
return (
<span className="nfw-num" data-dir={dir ?? undefined} data-still={still || undefined} style={{ fontSize: size, fontWeight: 500, "--nfw-stagger": `${stagger}ms` } as CSSProperties} aria-live={live ? "polite" : undefined} aria-atomic={live || undefined}>
<span className="nfw-sr">
{prefix}
{text}
{suffix}
</span>
<span ref={row} className="nfw-line" aria-hidden>
{prefix && <span className="nfw-aff">{prefix}</span>}
{cells.map((c) => (
<span key={c.key} className={c.digit ? "nfw-cell" : "nfw-cell nfw-sep"} data-fresh={c.fresh || undefined} style={{ "--nfw-i": c.i } as CSSProperties}>
{c.ch}
</span>
))}
{suffix && <span className="nfw-aff">{suffix}</span>}
</span>
</span>
);
}
/** Compare from the right: a cell is "fresh" when the character at that position (from the end) changed. */
function splitCells(next: string, previous: string, generation: number, generations: number[]): Cell[] {
const cells: Cell[] = [];
const lengthShift = next.length - previous.length;
let freshCount = 0;
for (let index = next.length - 1; index >= 0; index--) {
const char = next[index];
const fromEnd = next.length - 1 - index;
const fresh = previous !== "" && char !== previous[index - lengthShift];
if (fresh || generations[fromEnd] === undefined) generations[fromEnd] = generation;
cells.unshift({ ch: char, digit: /\d/.test(char), fresh, i: fresh ? freshCount : 0, key: `${fromEnd}-${generations[fromEnd]}` });
if (fresh) freshCount++;
}
generations.length = next.length;
return cells;
}
/* NumberFlow. Prefix nfw-. */
.nfw-root,
.nfw-root * {
box-sizing: border-box;
}
:where(.nfw-root) button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
.nfw-root {
--nfw-ease: cubic-bezier(0.22, 1, 0.36, 1);
--nfw-pop: cubic-bezier(0.24, 1.34, 0.38, 1);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
font-family: var(--ts-font, system-ui, sans-serif);
}
.nfw-num {
--nfw-stagger: 36ms;
position: relative;
display: inline-block;
color: var(--ts-fg);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
transition: color 0.22s var(--nfw-ease);
}
.nfw-num[data-dir="up"] {
color: var(--ts-success);
}
.nfw-num[data-dir="down"] {
color: var(--ts-danger);
}
.nfw-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.nfw-line {
display: inline-flex;
align-items: baseline;
justify-content: flex-end; /* while the width glides, a new leading digit overflows to the left */
white-space: pre;
transition: width 0.42s var(--nfw-ease);
}
.nfw-cell {
--nfw-i: 0;
display: inline-block;
}
.nfw-sep {
opacity: 0.55;
}
.nfw-aff {
opacity: 0.55;
font-size: 0.7em;
margin: 0 0.08em;
}
.nfw-cell[data-fresh] {
animation: nfw-in 0.42s var(--nfw-pop) backwards;
animation-delay: calc(var(--nfw-i) * var(--nfw-stagger, 36ms));
}
@keyframes nfw-in {
0% {
opacity: 0;
filter: blur(5px);
scale: 0.6;
translate: 0 0.18em;
}
to {
opacity: 1;
filter: none;
scale: 1;
translate: 0 0;
}
}
.nfw-num[data-still] .nfw-cell[data-fresh] {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.nfw-cell[data-fresh] {
animation: none;
}
}
.nfw-row {
display: flex;
gap: 8px;
}
.nfw-btn {
width: 36px;
height: 36px;
border-radius: 999px;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
color: var(--ts-fg);
display: grid;
place-items: center;
cursor: pointer;
transition: scale 0.22s var(--nfw-ease), background 0.16s var(--nfw-ease);
}
.nfw-btn:hover {
background: color-mix(in srgb, var(--ts-fg) 6%, var(--ts-raised));
}
.nfw-btn:active {
scale: 0.96;
}
.nfw-btn:focus-visible {
outline: 2px solid var(--ts-focus, #3446eb);
outline-offset: 2px;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { NumberFlow } from "./NumberFlow";
import "./NumberFlow.css";
export default function Demo() {
return <NumberFlow />;
}