StatTile

Ticker, delta chip and sparkline on a single clock, with the delta's colour on a semantic token rather than the brand accent.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/stat-tile.json

Pulls in sparkline, delta-chip.

Stat · canvas + DOMcomposed
Total revenue$0.0Kup 0.0%
Orders0down 0.0%

StatTileesc or double-click to exit full screendouble-tap to exit

data/stat-tile.tsx
"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