Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

NumberFlow.tsx189 lines
"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.css117 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { NumberFlow } from "./NumberFlow";
import "./NumberFlow.css";

export default function Demo() {
  return <NumberFlow />;
}