bottega

effects

Number Ticker

A per-digit slot-machine ticker. Each digit occupies its own vertical drum strip and springs through sequential values to its target, with the most-significant digit landing first for a left-to-right cascade. Fixed per-position digit width (tabular-nums) prevents layout jitter, and a frosted-glass vignette mask hides the rolling neighbours. Triggers in-view via IntersectionObserver, re-animates on value change, and snaps instantly under reduced motion.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/number-ticker

Usage

Usagetsx
<NumberTicker value={0} />

Props

PropTypeDefaultDescription
value*numberTarget number to count to. Changing it re-animates from the current value.
startValuenumber0Value to count from on first in-view trigger.
durationnumber1200Total animation budget in ms. Stagger + spring delay derive from this.
delaynumber0Delay in ms before animating once in view.
decimalPlacesnumber0Fractional digits to display and animate.
prefixstringStatic string prepended (e.g. "$", "+"). Not animated.
suffixstringStatic string appended (e.g. "%", "K+"). Not animated.
directionenumupDigit scroll direction. "up" rolls toward larger values.
flashbooleantrueAccent landing-flash on settle.
groupingbooleantrueRender grouping separators (commas) between thousands.
delimiterbooleanfalseUse a hairline divider (border-border) instead of a comma glyph between groups.
oncebooleantrueAnimate only the first time the element enters the viewport.
classNamestringForwarded to the root span (font, size, color overrides).

Source

number-ticker.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  motion,
  useReducedMotion,
  useSpring,
  useMotionValue,
  type MotionValue,
} from "motion/react";
import styles from "./number-ticker.module.css";

/* Per-digit spring — snappy-premium, not bouncy (creative brief). */
const SPRING = { stiffness: 280, damping: 28, mass: 0.6 } as const;

/* Stagger: most-significant digit leads, least lags. Fraction of the total
   animation budget per position. These are SCENE timings (seconds-domain),
   distinct from the --duration CSS token used for UI transitions (~300ms). */
const STAGGER_COEFF = 0.04;

export type NumberTickerProps = {
  /** Target number to count to. Changing it re-animates from the current value. */
  value: number;
  /** Value to count from on first in-view trigger. */
  startValue?: number;
  /** Total animation budget in ms. Stagger + spring delay derive from this. */
  duration?: number;
  /** Delay in ms before animating once in view. */
  delay?: number;
  /** Fractional digits to display and animate. */
  decimalPlaces?: number;
  /** Static string prepended (e.g. "$", "+"). Not animated. */
  prefix?: string;
  /** Static string appended (e.g. "%", "K+"). Not animated. */
  suffix?: string;
  /** Digit scroll direction. "up" rolls toward larger values. */
  direction?: "up" | "down";
  /** Accent landing-flash on settle. */
  flash?: boolean;
  /** Render grouping separators (commas) between thousands. */
  grouping?: boolean;
  /** Use a hairline divider (border-border) instead of a comma glyph between groups. */
  delimiter?: boolean;
  /** Animate only the first time the element enters the viewport. */
  once?: boolean;
  /** Forwarded to the root span (font, size, color overrides). */
  className?: string;
};

/* Format a number into the fixed-width digit string we animate. Returns the
   integer + fractional digit characters and the human-readable label. */
function format(
  value: number,
  decimalPlaces: number,
  grouping: boolean,
): { label: string; intGroups: string[]; fracDigits: string[] } {
  const fixed = Math.abs(value).toFixed(decimalPlaces);
  const [intPart, fracPart = ""] = fixed.split(".");

  const groups: string[] = [];
  if (grouping) {
    let rest = intPart;
    while (rest.length > 3) {
      groups.unshift(rest.slice(-3));
      rest = rest.slice(0, -3);
    }
    groups.unshift(rest);
  } else {
    groups.push(intPart);
  }

  const sign = value < 0 ? "-" : "";
  const label =
    sign + groups.join(",") + (fracPart ? "." + fracPart : "");

  return {
    label,
    intGroups: groups,
    fracDigits: fracPart.split(""),
  };
}

/* A single drum strip: a stacked 0–9 column that springs its y-offset so the
   target digit sits centred in the masked window. */
function DigitDrum({
  target,
  delaySec,
  direction,
  digitHeight,
  flash,
  settleKey,
}: {
  target: number;
  delaySec: number;
  direction: "up" | "down";
  digitHeight: number;
  flash: boolean;
  settleKey: number;
}) {
  const cells = direction === "up" ? DIGITS : DIGITS_DESC;
  // Row index of the target glyph within the stacked column. For "down" the
  // column is reversed, so the same digit sits at a different row.
  const targetRow = direction === "up" ? target : 9 - target;
  const settled = -targetRow * digitHeight;

  const y = useMotionValue(0);
  const spring = useSpring(y, SPRING);
  const [landed, setLanded] = useState(false);

  useEffect(() => {
    setLanded(false);
    const id = window.setTimeout(() => {
      // Slide the column so the target row centres in the masked window.
      y.set(settled);
    }, delaySec * 1000);

    const unsub = spring.on("change", (latest) => {
      if (Math.abs(latest - settled) < 0.5) setLanded(true);
    });

    return () => {
      window.clearTimeout(id);
      unsub();
    };
    // settleKey forces a fresh roll when the value prop changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, delaySec, digitHeight, settleKey, settled]);

  return (
    <span
      className={`${styles.strip} ${styles.masked}`}
      style={{ ["--nt-digit-h" as string]: `${digitHeight}px` }}
    >
      <DrumColumn
        spring={spring}
        target={target}
        cells={cells}
        landed={landed && flash}
      />
    </span>
  );
}

const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const DIGITS_DESC = [...DIGITS].reverse();

function DrumColumn({
  spring,
  target,
  cells,
  landed,
}: {
  spring: MotionValue<number>;
  target: number;
  cells: number[];
  landed: boolean;
}) {
  return (
    <motion.span className={styles.column} style={{ y: spring }}>
      {cells.map((n) => (
        <span
          key={n}
          className={[
            styles.cell,
            n === target ? styles.cellOn : styles.cellOff,
            n === target && landed ? styles.flash : "",
          ]
            .filter(Boolean)
            .join(" ")}
        >
          {n}
        </span>
      ))}
    </motion.span>
  );
}

export function NumberTicker({
  value,
  startValue = 0,
  duration = 1200,
  delay = 0,
  decimalPlaces = 0,
  prefix,
  suffix,
  direction = "up",
  flash = true,
  grouping = true,
  delimiter = false,
  once = true,
  className = "",
}: NumberTickerProps) {
  const rootRef = useRef<HTMLSpanElement>(null);

  // Mount + reduced-motion gate (contract snippet — avoids flash & React #418).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;

  // In-view trigger; until fired the drum shows startValue.
  const [inView, setInView] = useState(false);
  // settleKey bumps on each (re)trigger so digits re-roll from current state.
  const [settleKey, setSettleKey] = useState(0);
  // Measured glyph box for fixed strip height (post-mount, hydration-safe).
  const [digitHeight, setDigitHeight] = useState(0);
  const measureRef = useRef<HTMLSpanElement>(null);

  const target = format(value, decimalPlaces, grouping);
  const start = format(startValue, decimalPlaces, grouping);

  const animate = mounted && !reduce;

  // Measure one glyph after mount.
  useEffect(() => {
    if (!animate || !measureRef.current) return;
    setDigitHeight(measureRef.current.getBoundingClientRect().height);
  }, [animate]);

  // IntersectionObserver.
  useEffect(() => {
    if (!animate || !rootRef.current) return;
    const el = rootRef.current;
    const obs = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            setInView(true);
            if (once) obs.disconnect();
          } else if (!once) {
            setInView(false);
          }
        }
      },
      { threshold: 0.4 },
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, [animate, once]);

  // Re-trigger when value changes while already in view.
  useEffect(() => {
    if (inView) setSettleKey((k) => k + 1);
  }, [value, inView]);

  const ariaLabel = `${prefix ?? ""}${target.label}${suffix ?? ""}`;

  // Static base — server, first client render, and reduced-motion all use this.
  // Identical markup on server & first client paint => no hydration mismatch.
  const showLabel = !animate || !inView ? start.label : target.label;
  const staticLabel = !animate
    ? target.label // reduced motion / pre-mount: snap to final value
    : showLabel;

  // Total digit count drives the stagger so the MSB leads.
  const intDigits = target.intGroups.join("");
  const totalDigits = intDigits.length + target.fracDigits.length;

  return (
    <span
      ref={rootRef}
      role="img"
      aria-label={ariaLabel}
      aria-live="off"
      className={[styles.root, className].filter(Boolean).join(" ")}
    >
      {/* Hidden glyph used to measure a single digit's box once mounted. */}
      {animate && digitHeight === 0 && (
        <span
          ref={measureRef}
          aria-hidden="true"
          style={{ position: "absolute", visibility: "hidden", pointerEvents: "none" }}
        >
          0
        </span>
      )}

      {prefix && (
        <span aria-hidden="true" className={styles.affix}>
          {prefix}
        </span>
      )}

      {animate && inView && digitHeight > 0 ? (
        <DrumValue
          target={target}
          duration={duration}
          delay={delay}
          direction={direction}
          digitHeight={digitHeight}
          flash={flash}
          delimiter={delimiter}
          settleKey={settleKey}
          totalDigits={totalDigits}
        />
      ) : (
        <span aria-hidden="true">{staticLabel}</span>
      )}

      {suffix && (
        <span aria-hidden="true" className={styles.affix}>
          {suffix}
        </span>
      )}
    </span>
  );
}

/* Lays out grouped integer digit-drums, comma delimiters, decimal point, and
   fractional digit-drums. The stagger index counts across ALL digits so the
   most-significant leads by ~`duration * 0.04 * (n - i)`. */
function DrumValue({
  target,
  duration,
  delay,
  direction,
  digitHeight,
  flash,
  delimiter,
  settleKey,
  totalDigits,
}: {
  target: ReturnType<typeof format>;
  duration: number;
  delay: number;
  direction: "up" | "down";
  digitHeight: number;
  flash: boolean;
  delimiter: boolean;
  settleKey: number;
  totalDigits: number;
}) {
  let digitIndex = -1;
  const delayFor = (idx: number) =>
    delay / 1000 + (duration / 1000) * STAGGER_COEFF * (totalDigits - idx);

  return (
    <>
      {target.intGroups.map((group, gi) => (
        <span key={`g${gi}`} style={{ display: "inline-flex" }}>
          {gi > 0 &&
            (delimiter ? (
              <span aria-hidden="true" className={styles.delimiter} />
            ) : (
              <span aria-hidden="true" className={styles.affix}>
                ,
              </span>
            ))}
          {group.split("").map((ch, ci) => {
            digitIndex += 1;
            const idx = digitIndex;
            return (
              <DigitDrum
                key={`g${gi}d${ci}`}
                target={Number(ch)}
                delaySec={delayFor(idx)}
                direction={direction}
                digitHeight={digitHeight}
                flash={flash}
                settleKey={settleKey}
              />
            );
          })}
        </span>
      ))}

      {target.fracDigits.length > 0 && (
        <>
          <span aria-hidden="true" className={styles.affix}>
            .
          </span>
          {target.fracDigits.map((ch, ci) => {
            digitIndex += 1;
            const idx = digitIndex;
            return (
              <DigitDrum
                key={`f${ci}`}
                target={Number(ch)}
                delaySec={delayFor(idx)}
                direction={direction}
                digitHeight={digitHeight}
                flash={flash}
                settleKey={settleKey}
              />
            );
          })}
        </>
      )}
    </>
  );
}

Dependencies

  • motion
  • @bottega/tokens