WUWensity UI

Number Ticker

Digits that roll or spin to a new value, with currency, percent and compact formats.

12,480

Installation

npx shadcn@latest add https://layro.dev/r/wensity/count-up-animation.json

Or, with @layro in your components.json:

npx shadcn@latest add @layro/wensity/count-up-animation

Code

"use client";

import * as React from "react";
import {
  animate,
  cancelFrame,
  frame,
  motionValue,
  useInView,
  useReducedMotion,
  type AnimationPlaybackControlsWithThen,
  type MotionValue,
} from "framer-motion";
import { cn } from "@/lib/utils";

export type NumberTickerVariant = "count" | "roll";

export interface NumberTickerProps
  extends Omit<React.ComponentPropsWithoutRef<"span">, "children" | "prefix"> {
  /** The number to show. Changing it animates from whatever is on screen. */
  value: number;
  /** Where the first run starts. Default: `0`. */
  from?: number;
  /**
   * `roll` turns each digit straight to its new value.
   * `count` spins the digits like a counter, low digits turning most.
   * Default: `"roll"`.
   */
  variant?: NumberTickerVariant;
  /** Run length in seconds. Default: `1` for roll, `2.2` for count. */
  duration?: number;
  /** Seconds to wait before the first run. Default: `0`. */
  delay?: number;
  /** Start when scrolled into view, or as soon as it mounts. Default: `"view"`. */
  trigger?: "view" | "mount";
  /** Locale(s) passed to `Intl.NumberFormat`. Default: the browser locale. */
  locales?: string | string[];
  /** `Intl.NumberFormat` options: currency, percent, compact, decimals. */
  format?: Intl.NumberFormatOptions;
  /** Text before the number, such as `"+"`. */
  prefix?: string;
  /** Text after the number, such as `"+"` or `" users"`. */
  suffix?: string;
  /** Called when a run settles. */
  onComplete?: () => void;
}

/* ────────────────────────────────────────────────────────────────────────
 * Layout: what Intl says the target looks like, split into the parts that
 * turn (digits) and the parts that stay put (currency, %, K, separators).
 * ──────────────────────────────────────────────────────────────────────── */

interface Layout {
  lead: string;
  trail: string;
  /** Fraction digits shown. */
  fd: number;
  decimal: string;
  group: string;
  /** Raw value × scale = the number Intl prints (100 for %, 1/1000 for K). */
  scale: number;
  /** Localised digit glyphs 0-9. */
  digits: string[];
  /** Places k with a group separator right of column k, per integer length. */
  separators: (length: number) => ReadonlySet<number>;
  text: string;
}

const MAX_PLACES = 21;
const STRIP = 11; // 0-9 plus a trailing 0 so 9 turns into 0
/**
 * Extra full turns a low digit makes in count mode, so it reads as counting.
 * Kept to one: more makes digits strobe instead of roll.
 */
const MAX_TURNS = 1;
/**
 * Timed curves, not springs: a spring's tail keeps a digit creeping the last
 * few pixels long after it looks done. Both land at zero speed, exactly on
 * the digit, at the stated duration.
 */
const ROLL_EASE = [0.3, 0.5, 0.2, 1] as const;
const COUNT_EASE = [0.33, 0.2, 0.3, 1] as const;
const PRESENCE = { type: "spring", bounce: 0, visualDuration: 0.42 } as const;

/*
 * Each digit turns inside its own line, faded at the top and bottom, so a
 * digit slides out through a soft edge and the neighbours never show. The
 * line is taller than the text (taken back with negative margins) to leave
 * room for that fade, and the fade is fitted to the font's real ink so it
 * runs as long as possible without dimming a digit at rest. Horizontal
 * padding, also taken back, keeps wide glyphs and tight tracking unclipped.
 */
const LINE = 1.5;
const LINE_TRIM = `${-(LINE - 1.15) / 2}em`;
const PAD_X = "0.3em";
const FALLBACK_MASK =
  "linear-gradient(to bottom, transparent 0, #000 0.38em, #000 calc(100% - 0.3em), transparent 100%)";

let measureContext: CanvasRenderingContext2D | null | undefined;

/** A mask whose fades end exactly where the digits' ink begins. */
function inkMask(row: HTMLElement): string | null {
  if (measureContext === undefined) {
    measureContext = document.createElement("canvas").getContext("2d");
  }
  const ctx = measureContext;
  if (!ctx) return null;
  const cs = getComputedStyle(row);
  const size = parseFloat(cs.fontSize);
  const line = parseFloat(cs.lineHeight);
  if (!size || !Number.isFinite(line)) return null;
  ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
  const m = ctx.measureText("0123456789");
  if (!m.fontBoundingBoxAscent) return null;
  const baseline = (line - (m.fontBoundingBoxAscent + m.fontBoundingBoxDescent)) / 2 + m.fontBoundingBoxAscent;
  const margin = size * 0.025;
  const top = Math.max(1, baseline - m.actualBoundingBoxAscent - margin);
  const bottom = Math.min(line - 1, baseline + m.actualBoundingBoxDescent + margin);
  if (bottom <= top) return null;
  // Smoothstep stops, so the fade has no visible start or end line.
  const steps = [0, 0.2, 0.4, 0.6, 0.8, 1];
  const ease = (f: number) => f * f * (3 - 2 * f);
  const stops = [
    ...steps.map((f) => `rgba(0,0,0,${ease(f).toFixed(3)}) ${(top * f).toFixed(2)}px`),
    ...steps.map((f) => `rgba(0,0,0,${ease(1 - f).toFixed(3)}) ${(bottom + (line - bottom) * f).toFixed(2)}px`),
  ];
  return `linear-gradient(to bottom, ${stops.join(", ")})`;
}

function makeFormatter(
  locales: string | string[] | undefined,
  options: Intl.NumberFormatOptions,
) {
  try {
    return new Intl.NumberFormat(locales, options);
  } catch {
    return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
  }
}

function readParts(formatter: Intl.NumberFormat, n: number) {
  let int = "";
  let frac = "";
  let lead = "";
  let trail = "";
  let decimal = ".";
  let seenNumber = false;
  for (const part of formatter.formatToParts(n)) {
    switch (part.type) {
      case "integer":
        int += part.value;
        seenNumber = true;
        break;
      case "fraction":
        frac += part.value;
        break;
      case "decimal":
        decimal = part.value;
        break;
      case "group":
      case "minusSign":
      case "plusSign":
        break;
      default:
        if (seenNumber) trail += part.value;
        else lead += part.value;
    }
  }
  return { int, frac, lead, trail, decimal };
}

function buildLayout(
  value: number,
  locales: string | string[] | undefined,
  format: Intl.NumberFormatOptions | undefined,
  prefix: string,
  suffix: string,
): Layout {
  const options = format ?? {};
  const full = makeFormatter(locales, options);
  const latn = makeFormatter(locales, { ...options, numberingSystem: "latn" });
  const abs = Number.isFinite(value) ? Math.abs(value) : 0;

  const target = readParts(latn, abs);
  const shown = Number(`${target.int || "0"}.${target.frac || "0"}`);
  let scale = 1;
  if (abs > 0 && shown > 0) {
    scale = shown / abs;
  } else {
    const one = readParts(latn, 1);
    scale = Number(`${one.int || "1"}.${one.frac || "0"}`) || 1;
  }

  const resolved = full.resolvedOptions() as Intl.ResolvedNumberFormatOptions & {
    useGrouping?: unknown;
  };
  const plain = makeFormatter(locales, {
    numberingSystem: "latn",
    maximumFractionDigits: 0,
    useGrouping: resolved.useGrouping as Intl.NumberFormatOptions["useGrouping"],
  });
  const groupPart = makeFormatter(locales, { numberingSystem: "latn" })
    .formatToParts(1234567)
    .find((part) => part.type === "group");

  const digitFormatter = makeFormatter(locales, {
    numberingSystem: resolved.numberingSystem,
    useGrouping: false,
    maximumFractionDigits: 0,
  });
  const digits = Array.from({ length: 10 }, (_, d) => digitFormatter.format(d));

  const cache = new Map<number, ReadonlySet<number>>();
  const separators = (length: number) => {
    const n = Math.min(Math.max(1, length), MAX_PLACES);
    const hit = cache.get(n);
    if (hit) return hit;
    const chunks = plain
      .formatToParts(10 ** (n - 1))
      .filter((part) => part.type === "integer")
      .map((part) => part.value.length);
    const set = new Set<number>();
    let acc = 0;
    for (let i = chunks.length - 1; i > 0; i -= 1) {
      acc += chunks[i] ?? 0;
      set.add(acc);
    }
    cache.set(n, set);
    return set;
  };

  return {
    lead: prefix + target.lead,
    trail: target.trail + suffix,
    fd: target.frac.length,
    decimal: target.decimal,
    group: groupPart?.value ?? ",",
    scale,
    digits,
    separators,
    text: `${prefix}${full.format(Number.isFinite(value) ? value : 0)}${suffix}`,
  };
}

/** Every shown digit as one integer, without the decimal point. */
function scaledInt(raw: number, layout: Layout) {
  return Math.round(Math.abs(raw) * layout.scale * 10 ** layout.fd);
}

/** How many integer digits a scaled integer has. */
function lengthOf(n: number, fd: number) {
  return Math.min(MAX_PLACES, String(Math.floor(n / 10 ** fd)).length);
}

function digitAt(n: number, idx: number) {
  return Math.floor(n / 10 ** idx) % 10;
}

/** Width of the digit at a (possibly fractional) turn position. */
function glyphWidth(metrics: Metrics, turns: number) {
  const pos = wrap(turns);
  const low = Math.floor(pos);
  const w0 = metrics.digits[low % 10] ?? 0;
  const w1 = metrics.digits[(low + 1) % 10] ?? 0;
  return w0 + (w1 - w0) * (pos - low);
}

function smooth(x: number) {
  const t = Math.min(1, Math.max(0, x));
  return t * t * (3 - 2 * t);
}

function wrap(turns: number) {
  return ((turns % 10) + 10) % 10;
}

/** Column `idx` (0 = lowest shown place) is part of the number. */
function placeShows(n: number, idx: number, fd: number) {
  return idx <= fd || n >= 10 ** idx;
}

interface Slot {
  presence: MotionValue<number>;
  on: boolean;
}

interface Column {
  turns: MotionValue<number>;
  /** Where the current run started and ends, in unwrapped turns. */
  start: number;
  target: number;
  /** How much of the number this column was (0-1) when the run started. */
  startShown: number;
  /** Whether it is part of the number when the run ends. */
  targetOn: boolean;
  /** How much of the number it is right now, 0 to 1. */
  shown: number;
}

interface Metrics {
  digits: number[];
  group: number;
  minus: number;
  font: number;
}

/**
 * NumberTicker
 *
 * A number that spins up like a counter (`count`) or turns each digit
 * straight to its new value (`roll`). Formatting comes from
 * `Intl.NumberFormat`, so currency, percent, compact and locale grouping
 * all work. Each column is as wide as the digit it shows, so proportional
 * fonts keep their natural spacing, and digits that appear or disappear
 * (999 to 1,000) open their width instead of jumping. The finished value is
 * always in the DOM for screen readers and crawlers.
 */
export function NumberTicker({ variant = "roll", ...props }: NumberTickerProps) {
  return <NumberTickerEngine key={variant} variant={variant} {...props} />;
}

function NumberTickerEngine({
  value,
  from = 0,
  variant = "roll",
  duration,
  delay = 0,
  trigger = "view",
  locales,
  format,
  prefix = "",
  suffix = "",
  onComplete,
  className,
  style,
  ...rest
}: NumberTickerProps) {
  const rootRef = React.useRef<HTMLSpanElement>(null);
  const rowRef = React.useRef<HTMLSpanElement>(null);
  const measureRef = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(rootRef, { once: true, amount: 0.5 });
  const reduceMotion = useReducedMotion();
  const started = trigger === "mount" || inView;

  const safeValue = Number.isFinite(value) ? value : 0;
  const safeFrom = Number.isFinite(from) ? from : 0;
  const formatKey = JSON.stringify(format ?? null);
  const localeKey = JSON.stringify(locales ?? null);
  const layout = React.useMemo(
    () => buildLayout(safeValue, locales, format, prefix, suffix),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [safeValue, localeKey, formatKey, prefix, suffix],
  );
  // What the first paint shows. Fixed after mount, so React never rewrites
  // inline styles that the motion loop owns.
  const [first] = React.useState(() => ({
    n: scaledInt(safeFrom, layout),
    fd: layout.fd,
    negative: safeFrom < 0,
  }));

  // Columns never unmount: a shrinking number folds them to zero width.
  const needed = Math.max(
    lengthOf(scaledInt(safeFrom, layout), layout.fd),
    lengthOf(scaledInt(safeValue, layout), layout.fd),
  );
  const [maxPlaces, setMaxPlaces] = React.useState(needed);
  if (needed > maxPlaces) setMaxPlaces(needed);
  const places = Math.max(maxPlaces, needed);
  const columnCount = places + layout.fd;

  // Live copies for the motion loop, which runs outside render.
  const layoutRef = React.useRef(layout);
  const placesRef = React.useRef(places);
  const onCompleteRef = React.useRef(onComplete);
  const instantRef = React.useRef(false);
  React.useLayoutEffect(() => {
    layoutRef.current = layout;
    placesRef.current = places;
    onCompleteRef.current = onComplete;
    instantRef.current = Boolean(reduceMotion);
  });

  // The number on screen as a scaled integer: `from` until the first run.
  const shownRef = React.useRef(first.n);

  const separatorPlaces = React.useMemo(() => {
    const all = new Set<number>();
    for (let n = 1; n <= places; n += 1) {
      for (const k of layout.separators(n)) all.add(k);
    }
    return all;
  }, [layout, places]);

  /* ── Motion state, kept outside React ─────────────────────────────── */

  const metricsRef = React.useRef<Metrics | null>(null);
  const columnsRef = React.useRef(new Map<number, Column>());
  const signRef = React.useRef<Slot | null>(null);
  const paintRef = React.useRef<() => void>(() => {});
  const scheduledRef = React.useRef(false);

  // One paint per frame, however many motion values changed.
  const process = React.useCallback(() => {
    scheduledRef.current = false;
    paintRef.current();
  }, []);
  const schedule = React.useCallback(() => {
    if (scheduledRef.current) return;
    scheduledRef.current = true;
    frame.render(process);
  }, [process]);

  const makeSlot = React.useCallback(
    (on: boolean): Slot => {
      const presence = motionValue(on ? 1 : 0);
      presence.on("change", schedule);
      return { presence, on };
    },
    [schedule],
  );

  const getColumn = React.useCallback(
    (idx: number) => {
      let col = columnsRef.current.get(idx);
      if (!col) {
        const n = shownRef.current ?? 0;
        const digit = digitAt(n, idx);
        const on = placeShows(n, idx, layoutRef.current.fd);
        const turns = motionValue(digit);
        turns.on("change", schedule);
        col = {
          turns,
          start: digit,
          target: digit,
          startShown: on ? 1 : 0,
          targetOn: on,
          shown: on ? 1 : 0,
        };
        columnsRef.current.set(idx, col);
      }
      return col;
    },
    [schedule],
  );

  const toggle = React.useCallback((slot: Slot, on: boolean) => {
    if (slot.on === on) return;
    slot.on = on;
    if (instantRef.current) {
      slot.presence.stop();
      slot.presence.set(on ? 1 : 0);
    } else {
      animate(slot.presence, on ? 1 : 0, PRESENCE);
    }
  }, []);

  const paint = () => {
    const row = rowRef.current;
    const metrics = metricsRef.current;
    if (!row || !metrics) return;
    const L = layoutRef.current;

    // How much each column belongs to the number, top down. Opening and
    // closing ride on the column's own roll, so the number widens as a
    // leading digit rolls in, narrows as one rolls away to 0, and every
    // width change lands exactly when the digits do. A column is never less
    // shown than the one on its left, so no gap and no stray leading 0.
    let higher = 0;
    let length = 1;
    for (let idx = placesRef.current + L.fd - 1; idx >= 0; idx -= 1) {
      const col = getColumn(idx);
      const now = col.turns.get();
      const span = col.target - col.start;
      const moved = Math.abs(now - col.start);
      const target = col.targetOn ? 1 : 0;
      let own: number;
      if (idx <= L.fd) own = 1;
      else if (col.startShown === target) own = target;
      else if (Math.abs(span) < 1e-6) own = 0;
      else if (target === 1) own = col.startShown + (1 - col.startShown) * smooth(moved / Math.min(0.7, Math.abs(span)));
      else own = col.startShown * (1 - smooth(moved / Math.abs(span)));
      col.shown = Math.max(own, higher);
      higher = col.shown;
      if (col.shown > 0.001) length = Math.max(length, idx - L.fd + 1);
    }
    const groups = L.separators(length);

    for (const el of row.querySelectorAll<HTMLElement>("[data-ticker-col]")) {
      const col = columnsRef.current.get(Number(el.dataset.idx));
      const strip = el.firstElementChild?.nextElementSibling as HTMLElement | null;
      if (!col || !strip) continue;
      const now = col.turns.get();
      // Width travels from the start digit's to the end digit's along the
      // roll itself, ignoring the digits passing by, so the number never
      // shakes sideways mid-roll.
      const span = col.target - col.start;
      const progress = Math.abs(span) > 1e-6 ? Math.min(1, Math.max(0, (now - col.start) / span)) : 1;
      const from = glyphWidth(metrics, col.start);
      const width = from + (glyphWidth(metrics, col.target) - from) * progress;
      el.style.width = `${width * col.shown}px`;
      // Fade in only once there is room, so an opening digit never sits
      // over its neighbours.
      el.style.opacity = String(smooth((col.shown - 0.25) / 0.6));
      strip.style.transform = `translate3d(0, ${(-wrap(now) * 100) / STRIP}%, 0)`;
    }

    for (const el of row.querySelectorAll<HTMLElement>("[data-ticker-sep]")) {
      const place = Number(el.dataset.place);
      const shown = groups.has(place) ? (columnsRef.current.get(place + L.fd)?.shown ?? 0) : 0;
      el.style.width = `${metrics.group * shown}px`;
      el.style.opacity = String(smooth((shown - 0.3) / 0.7));
    }
    const signEl = row.querySelector<HTMLElement>("[data-ticker-sign]");
    if (signEl && signRef.current) {
      const p = signRef.current.presence.get();
      signEl.style.width = `${metrics.minus * p}px`;
      signEl.style.opacity = String(p);
    }
  };

  React.useLayoutEffect(() => {
    paintRef.current = paint;
  });

  /* ── Measuring: real glyph widths, so proportional digits stay put ── */

  React.useLayoutEffect(() => {
    const box = measureRef.current;
    if (!box) return;
    const read = () => {
      const widths = Array.from(box.children, (s) => s.getBoundingClientRect().width);
      const digits = widths.slice(0, 10);
      metricsRef.current = {
        digits,
        group: widths[10] ?? 0,
        minus: widths[11] ?? 0,
        font: parseFloat(getComputedStyle(box).fontSize) || 16,
      };
      const row = rowRef.current;
      const mask = row ? inkMask(row) : null;
      if (row && mask) row.style.setProperty("--ticker-mask", mask);
      paintRef.current();
    };
    read();
    const observer = new ResizeObserver(read);
    observer.observe(box);
    let alive = true;
    document.fonts?.ready.then(() => {
      if (alive) read();
    });
    return () => {
      alive = false;
      observer.disconnect();
    };
  }, [layout]);

  // New columns get state before they paint.
  React.useLayoutEffect(() => {
    for (let idx = 0; idx < columnCount; idx += 1) getColumn(idx);
    if (!signRef.current) signRef.current = makeSlot(first.negative);
    paintRef.current();
  });

  /* ── Runs ─────────────────────────────────────────────────────────── */

  const firstRunRef = React.useRef(true);
  const runLength = Math.max(0.05, duration ?? (variant === "roll" ? 1 : 2.2));

  React.useEffect(() => {
    if (!started) return;
    const wait = firstRunRef.current ? Math.max(0, delay) : 0;
    firstRunRef.current = false;

    const L = layoutRef.current;
    const n = scaledInt(safeValue, L);
    const previous = shownRef.current ?? 0;
    shownRef.current = n;
    const up = n >= previous;
    const change = Math.abs(n - previous);
    const count = placesRef.current + L.fd;

    if (signRef.current) toggle(signRef.current, safeValue < 0 && n > 0);

    const running: AnimationPlaybackControlsWithThen[] = [];
    const moving: Column[] = [];
    for (let idx = 0; idx < count; idx += 1) {
      const col = getColumn(idx);
      const digit = digitAt(n, idx);
      // Count restarts from what is on screen; roll keeps its digit path.
      const base = variant === "count" ? col.turns.get() : col.target;
      const here = wrap(base);
      let step = up ? (digit - here + 10) % 10 : -((here - digit + 10) % 10);
      if (Math.abs(step) < 1e-6 || Math.abs(Math.abs(step) - 10) < 1e-6) step = 0;
      if (variant === "count") {
        const laps = Math.min(MAX_TURNS, Math.floor(change / 10 ** idx / 10));
        step += (up ? 10 : -10) * laps;
      }
      col.start = col.turns.get();
      col.target = base + step;
      col.startShown = col.shown;
      col.targetOn = placeShows(n, idx, L.fd);

      if (reduceMotion) {
        col.turns.stop();
        col.turns.set(col.target);
        col.start = col.target;
        col.startShown = col.targetOn ? 1 : 0;
        continue;
      }
      if (Math.abs(col.turns.get() - col.target) < 1e-6) continue;
      moving.push(col);
    }

    if (variant === "count") {
      // One wave: every digit turns at the same speed, so the row reads as a
      // single motion. Digits with further to go start first (the low ones),
      // shorter trips join later, and everything lands together.
      const distance = (col: Column) => Math.abs(col.target - col.turns.get());
      const longest = Math.max(1, ...moving.map(distance));
      for (const col of moving) {
        const share = Math.max(0.4, distance(col) / longest);
        running.push(
          animate(col.turns, col.target, {
            duration: runLength * share,
            delay: wait + runLength * (1 - share),
            ease: COUNT_EASE,
          }),
        );
      }
    } else {
      for (const col of moving) {
        running.push(
          animate(col.turns, col.target, {
            duration: runLength,
            delay: wait,
            ease: ROLL_EASE,
          }),
        );
      }
    }
    schedule();

    let cancelled = false;
    const finish = () => {
      if (!cancelled) onCompleteRef.current?.();
    };
    if (running.length === 0) finish();
    else Promise.all(running.map((c) => new Promise<void>((r) => c.then(r)))).then(finish);
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [started, safeValue, layout, variant, runLength, reduceMotion]);

  React.useEffect(
    () => () => {
      cancelFrame(process);
      for (const col of columnsRef.current.values()) col.turns.stop();
      signRef.current?.presence.stop();
    },
    [process],
  );

  /* ── Markup ───────────────────────────────────────────────────────── */

  const firstGroups = layout.separators(lengthOf(first.n, first.fd));

  const slots: React.ReactNode[] = [];
  for (let place = places - 1; place >= -layout.fd; place -= 1) {
    const idx = place + layout.fd;
    const on = placeShows(first.n, idx, first.fd);
    const digit = digitAt(first.n, idx);
    slots.push(
      <span
        key={`c${place}`}
        data-ticker-col=""
        data-idx={idx}
        className="relative inline-block box-content text-center"
        style={{
          paddingInline: PAD_X,
          marginInline: `-${PAD_X}`,
          maskImage: `var(--ticker-mask, ${FALLBACK_MASK})`,
          WebkitMaskImage: `var(--ticker-mask, ${FALLBACK_MASK})`,
          ...(on ? null : { width: 0, opacity: 0 }),
        }}
      >
        {/* In-flow glyph: gives the column its baseline and first width. */}
        <span className="invisible">{layout.digits[digit]}</span>
        <span
          data-ticker-strip=""
          className="absolute flex flex-col items-center will-change-transform"
          style={{
            top: 0,
            left: PAD_X,
            right: PAD_X,
            transform: `translate3d(0, ${(-digit * 100) / STRIP}%, 0)`,
          }}
        >
          {Array.from({ length: STRIP }, (_, i) => (
            <span key={i} className="block">
              {layout.digits[i % 10]}
            </span>
          ))}
        </span>
      </span>,
    );
    if (place > 0 && separatorPlaces.has(place)) {
      const sepOn = firstGroups.has(place) && on;
      slots.push(
        <span
          key={`s${place}`}
          data-ticker-sep=""
          data-place={place}
          className="inline-block whitespace-pre"
          style={sepOn ? undefined : { width: 0, opacity: 0 }}
        >
          {layout.group}
        </span>,
      );
    }
    if (place === 0 && layout.fd > 0) {
      slots.push(
        <span key="decimal" className="whitespace-pre">
          {layout.decimal}
        </span>,
      );
    }
  }

  return (
    <span
      ref={rootRef}
      className={cn("relative inline-block whitespace-nowrap", className)}
      style={style}
      {...rest}
    >
      <span className="sr-only">{layout.text}</span>
      <span
        ref={rowRef}
        aria-hidden="true"
        className="inline-flex items-baseline"
        style={{ lineHeight: LINE, marginBlock: LINE_TRIM }}
      >
        <span
          data-ticker-sign=""
          className="inline-block"
          style={first.negative ? undefined : { width: 0, opacity: 0 }}
        >
          −
        </span>
        {layout.lead ? <span className="whitespace-pre">{layout.lead}</span> : null}
        {slots}
        {layout.trail ? <span className="whitespace-pre">{layout.trail}</span> : null}
      </span>
      <span
        ref={measureRef}
        aria-hidden="true"
        className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre"
      >
        {layout.digits.map((d, i) => (
          <span key={i} className="inline-block">
            {d}
          </span>
        ))}
        <span className="inline-block">{layout.group}</span>
        <span className="inline-block">−</span>
      </span>
    </span>
  );
}

Versions

  1. 1.0.0 · current

    Oct 27, 2026

    First release.

Similar components