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

PropTypeDefaultWhat it does
valuenumbernoneThe number to show.
formatIntl.NumberFormatOptionsnoneCurrency, percent, compact notation, fraction digits and the rest of Intl.NumberFormat.
localestring"en-US"Locale for formatting.
labelstringnoneRead before the value by screen readers, such as Live visitors.
...restComponentProps<"span">noneEvery native span attribute.

Anatomy

root.eq-ticker
spoken value.eq-ticker-srVisually hidden, polite live region with the plain formatted value.
digits.eq-ticker-digitsThe visible number, hidden from assistive tech.
cell.eq-ticker-cellOne place: a rolling digit or a still separator or symbol.
strip.eq-ticker-stripThe column of digits that slides inside a cell.
face.eq-ticker-faceOne 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index