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 };