StatTile
Ticker, delta chip and sparkline on a single clock, with the delta's colour on a semantic token rather than the brand accent.
Stat · canvas + DOMcomposed
Total revenue$0.0Kup 0.0%
Orders0down 0.0%
"use client";
import * as React from "react";
import { cx, useMotionRateRef, useMotionTrigger, useRevealClock } from "@pouriahlabs/wisp-ui";
import { DeltaChip } from "./delta-chip";
import { Sparkline } from "./sparkline";
/**
* StatTile — ticker, delta chip and sparkline, composed off one clock.
*
* The composition is the component. Three separate animations racing to finish
* at different times is what makes a metric card feel assembled; driving all
* three from a single progress value makes the number, the percentage and the
* line land together.
*
* The chip is `DeltaChip`, installed alongside rather than inlined here — the
* same chip belongs in table cells and beside inline figures, and a version of
* it trapped inside a metric card is a version nobody can reach.
*
* The count is written straight to the DOM from the clock, never through React
* state — the argument `ArcReveal`'s centre total makes applies here too: a
* re-render per frame to move two text nodes is the wrong trade, and it also
* re-rendered the composed `Sparkline` sixty times a second. The sparkline now
* runs its own clock at the same `duration`/`loop`, so the number, the chip and
* the line still land together without a shared render on the hot path.
*/
export interface StatTileProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
/** Final value. */
value: number;
/** Percentage change. Sign picks the semantic token and the arrow. */
delta?: number;
decimals?: number;
prefix?: string;
suffix?: string;
/** Trend series for the sparkline. */
data?: readonly number[];
/** Token for the sparkline. Defaults to direction-matched semantic colour. */
token?: string;
/** Reveal duration, ms. */
duration?: number;
/** Pause before replaying, ms. `0` (default) runs once and holds. */
loop?: number;
/** Locale for the value's grouping separators. Pass `false` for none. */
locale?: string | false;
}
export function StatTile({
label,
value,
delta,
decimals = 0,
prefix = "",
suffix = "",
data,
token,
duration = 1300,
loop = 0,
locale = "en-US",
className,
...props
}: StatTileProps) {
const valueRef = React.useRef<HTMLSpanElement>(null);
const chipRef = React.useRef<HTMLSpanElement>(null);
const raf = React.useRef(0);
const last = React.useRef(0);
// Speed rides the `dt` handed to the clock; the clock itself already stretches
// for `duration`. Read live so the slider retimes an in-flight count.
const rateRef = useMotionRateRef();
const up = (delta ?? 0) >= 0;
const accent = token ?? (up ? "--wisp-up" : "--wisp-down");
const { tick, reset } = useRevealClock({ duration, loop });
const formatValue = (n: number): string => {
const body =
locale === false
? n.toFixed(decimals)
: n.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return `${prefix}${body}${suffix}`;
};
// Move the two figures by writing text nodes, not by setting state.
const paint = (p: number) => {
if (valueRef.current) valueRef.current.textContent = formatValue(value * p);
const num = chipRef.current?.querySelector("[data-wisp-num]");
if (num) num.textContent = `${Math.abs((delta ?? 0) * p).toFixed(1)}%`;
};
// `useMotionTrigger` owns visibility, hidden-tab and reduced-motion above
// this, so `run` only fires when the tile should actually animate. A looping
// clock keeps ticking the whole time the tile is on screen; a run-once clock
// (`loop` 0) parks the frame loop the moment it pins at 1 — the settled
// figures are static text, and repainting them forever is the idle 60fps
// burn the canvas components' settle path exists to avoid.
const ref = useMotionTrigger<HTMLDivElement>(
{
replay: true,
rootMargin: "80px",
run: (done) => {
reset();
last.current = 0;
const step = (now: number) => {
const dt = (last.current ? now - last.current : 0) * rateRef.current.speed;
last.current = now;
const p = tick(dt, false);
paint(p);
if (loop <= 0 && p >= 1) {
done();
return;
}
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
},
settle: () => {
cancelAnimationFrame(raf.current);
paint(1);
},
cancel: () => cancelAnimationFrame(raf.current),
},
[duration, loop, value, delta, decimals, prefix, suffix, locale],
);
return (
<div ref={ref} className={cx("wisp-tile", className)} {...props}>
<span className="wisp-tile-label">{label}</span>
<span className="wisp-tile-row">
{/* Rendered at zero, then written every frame by the clock. */}
<span ref={valueRef} className="wisp-tile-value tabular-nums">
{formatValue(0)}
</span>
{/* `direction` comes from the settled delta, not the animating one —
a falling metric counts up through zero, and deriving the arrow
from the current value would show it rising for the first frame.
The wrapper is `display: contents`, so it adds no box; it only gives
the clock a handle on the chip's magnitude node. */}
{delta === undefined ? null : (
<span ref={chipRef} className="contents">
<DeltaChip value={0} direction={up ? 1 : -1} />
</span>
)}
</span>
{data && data.length > 1 ? (
<Sparkline
className="wisp-tile-spark"
data={data}
token={accent}
duration={duration}
loop={loop}
/>
) : null}
</div>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React