Domphy

numberTicker

A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call numberTicker() with no arguments for a working demo, or edit the code below live.

Implementation notes

IntersectionObserver-gated, one-shot-by-default scroll trigger (fails open to immediate play when IntersectionObserver is unavailable, e.g. non-browser test runtimes), optional delay, and a hand-rolled 1D spring-damper integrator (mass/stiffness/damping, tuned near-critically-damped so it decelerates into the target without overshoot) driving requestAnimationFrame-timed textContent writes — matches the 'fast start, gentle settle, no linear ticking' spec requirement. Digits are formatted via Intl.NumberFormat (locale + decimalPlaces, thousands separators included). Domphy has no bundled spring/physics library (same documented gap as this package's existing smoothCursor component), so the integrator is hand-written rather than pulled from a dependency — this is a faithful physical approximation, not a stub. Per-frame DOM writes are imperative (not through Domphy's reactive State.set()) to avoid per-frame render overhead, consistent with this package's existing guidance for continuous/high-frequency effects.

Status: ported · Reference: Magic UI original

// magicui "Number Ticker" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A large
// numeric stat that counts from a start value up (or down) to its target
// once the element first scrolls into the viewport, settling with a
// spring-damper deceleration (fast start, no-overshoot settle — an odometer
// feel) rather than a linear tick or a CSS keyframe count.
//
// Domphy has no bundled spring integrator (see `smoothCursor`'s header
// comment for the same caveat elsewhere in this package) — this hand-rolls
// the same mass/stiffness/damping integration loop `smoothCursor` uses,
// tuned near-critically-damped (damping just above 2*sqrt(stiffness*mass))
// so the displayed number decelerates into its target without visibly
// overshooting past it. Per the "continuous, high-frequency effect"
// guidance used elsewhere in this package (see `dock.ts`'s header comment),
// the per-frame digits are written imperatively to `textContent` inside the
// rAF loop rather than through `State.set()` on every frame.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeFluidSpacing } from "@domphy/theme";

export interface NumberTickerSpring {
  /** How fast oscillation dies out. Defaults to `26`. */
  damping?: number;
  /** How strongly the number is pulled toward its target. Defaults to `90`. */
  stiffness?: number;
  /** Perceived weight/inertia. Defaults to `1`. */
  mass?: number;
  /** Distance and speed below which the count is considered settled and the rAF loop stops. Defaults to `0.01`. */
  restDelta?: number;
}

export interface NumberTickerProps {
  /** Target number the count animates to (or from, when `direction` is `"down"`). Defaults to `100`. */
  value?: number;
  /** The other end of the count — animated from when `direction` is `"up"`, animated to when `"down"`. Defaults to `0`. */
  startValue?: number;
  /** `"up"` (default) counts from `startValue` to `value`; `"down"` counts from `value` to `startValue`. */
  direction?: "up" | "down";
  /** Seconds to wait, once visible, before the count starts. Defaults to `0`. */
  delay?: number;
  /** Decimal places to display. Defaults to `0`. */
  decimalPlaces?: number;
  /** `Intl.NumberFormat` locale, controlling thousands separators/decimal marks. Defaults to `"en-US"`. */
  locale?: string;
  /** Plays once the first time the element scrolls into view, then never replays. Defaults to `true`. */
  once?: boolean;
  /** Theme color family for the digits. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Spring tuning. See {@link NumberTickerSpring}. */
  spring?: NumberTickerSpring;
  style?: StyleObject;
}

const DEFAULT_SPRING: Required<NumberTickerSpring> = {
  damping: 26,
  stiffness: 90,
  mass: 1,
  restDelta: 0.01,
};

/**
 * A large numeric stat that counts up (or down) from a start value to its
 * target once scrolled into view, settling with a spring-damper
 * deceleration rather than a linear tick. Call with no arguments for a
 * working demo — counts from 0 to 100 the first time it's visible.
 */
function numberTicker(props: NumberTickerProps = {}): DomphyElement<"span"> {
  const targetValue = props.value ?? 100;
  const startValue = props.startValue ?? 0;
  const direction = props.direction ?? "up";
  const delaySeconds = props.delay ?? 0;
  const decimalPlaces = props.decimalPlaces ?? 0;
  const locale = props.locale ?? "en-US";
  const once = props.once ?? true;
  const color = props.color ?? "neutral";
  const spring = { ...DEFAULT_SPRING, ...(props.spring ?? {}) };

  const from = direction === "down" ? targetValue : startValue;
  const to = direction === "down" ? startValue : targetValue;

  const formatter = new Intl.NumberFormat(locale, {
    minimumFractionDigits: decimalPlaces,
    maximumFractionDigits: decimalPlaces,
  });

  return {
    span: formatter.format(from),
    dataNumberTicker: "true",
    style: {
      display: "inline-block",
      fontVariantNumeric: "tabular-nums",
      fontSize: () => themeFluidSpacing(32, 96),
      fontWeight: () => "800",
      color: (listener) => themeColor(listener, "shift-11", color),
      ...(props.style ?? {}),
    },
    _onMount: (node: ElementNode) => {
      const element = node.domElement as HTMLElement;
      let frameHandle: number | null = null;
      let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
      let observer: IntersectionObserver | null = null;
      let hasPlayed = false;

      const runSpring = () => {
        // Guard against overlapping runs (relevant when `once: false` and the
        // element re-enters view before the previous spring settled).
        if (frameHandle !== null) cancelAnimationFrame(frameHandle);

        let position = from;
        let velocity = 0;
        let lastTime = performance.now();

        const step = (time: number) => {
          const deltaSeconds = Math.min((time - lastTime) / 1000, 1 / 30);
          lastTime = time;

          // Spring-damper: force = -stiffness * displacement - damping * velocity.
          const acceleration =
            (-spring.stiffness * (position - to) - spring.damping * velocity) /
            spring.mass;
          velocity += acceleration * deltaSeconds;
          position += velocity * deltaSeconds;

          const settled =
            Math.abs(to - position) < spring.restDelta &&
            Math.abs(velocity) < spring.restDelta;

          element.textContent = formatter.format(settled ? to : position);

          frameHandle = settled ? null : requestAnimationFrame(step);
        };
        frameHandle = requestAnimationFrame(step);
      };

      const trigger = () => {
        if (hasPlayed && once) return;
        hasPlayed = true;
        if (timeoutHandle !== null) clearTimeout(timeoutHandle);
        timeoutHandle = setTimeout(runSpring, delaySeconds * 1000);
      };

      if (typeof IntersectionObserver !== "function") {
        // No IntersectionObserver support (e.g. a non-browser test runtime)
        // — fail open and play immediately rather than never playing.
        trigger();
      } else {
        observer = new IntersectionObserver(
          (entries) => {
            for (const entry of entries) {
              if (!entry.isIntersecting) continue;
              trigger();
              if (once) {
                observer?.disconnect();
                observer = null;
              }
            }
          },
          { threshold: 0.1 },
        );
        observer.observe(element);
      }

      node.addHook("Remove", () => {
        if (frameHandle !== null) cancelAnimationFrame(frameHandle);
        if (timeoutHandle !== null) clearTimeout(timeoutHandle);
        observer?.disconnect();
        observer = null;
      });
    },
  };
}

export { numberTicker };

← Back to Magic UI catalog