← All components

StatTile

A figure with what it is, which way it moved and against what, and how it got there.

The idea

Direction is carried by the arrow's shape and the sign, never by colour alone; whether the direction is good is a separate question the tile answers with good, so a fall in churn and a rise in revenue can both read as good news. A linked tile takes the hover as a whole, with the arrow in its corner darkening to say where it goes.

Composes Sparkline.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

StatTile — usage12 lines
import { StatTile } from "mihirmodi-ui";

<StatTile
  label="Monthly churn"
  value="2.1%"
  delta={-0.6}
  deltaLabel="pts vs Q2"
  formatDelta={(d) => d.toFixed(1)}
  good="low"                // a fall is good news
  trend={last8Months}
  to="/metrics/churn"
/>

Props

PropNotes
label / valuestring / ReactNodeWhat it is, and the figure already formatted.
delta / deltaLabelnumber / stringChange against the comparison period, and what that period is.
formatDelta(d) => stringDefaults to one decimal and a percent sign.
good"high" | "low"Whether up is good news.
trendnumber[]Drawn as a sparkline in the corner.
verdictReactNodeA short chip beside the label.
to / hrefstringMake the whole tile a link to its detail.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/StatTile.tsx101 lines
"use client";

import type { ReactNode } from "react";
import { Sparkline } from "./Sparkline.js";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";
import type { Good } from "../../lib/scale.js";

export type StatTileProps = {
  label: string;
  /** The figure, already formatted — "$4.2M", "91%". */
  value: ReactNode;
  /** Change against the comparison period, as a number: +3.2 means up 3.2. */
  delta?: number;
  /** What the delta is measured against — "vs last quarter". */
  deltaLabel?: string;
  /** How the delta is written. Defaults to one decimal and a percent sign. */
  formatDelta?: (d: number) => string;
  /** Up is good by default. `"low"` for churn, cost, response time. */
  good?: Good;
  trend?: number[];
  /** A short verdict chip beside the label — "On track". */
  verdict?: ReactNode;
  /** Make the whole tile the link to its detail. */
  to?: string;
  href?: string;
  link?: LinkComponent;
  className?: string;
};

/**
 * A number with the context to read it: what it is, what it is, which way it
 * moved and against what, and the shape of how it got there.
 *
 * Direction is carried by the arrow's shape and the sign, never by colour
 * alone, and whether that direction is good is a separate question the tile
 * answers with `good` — so a fall in churn and a rise in revenue can both read
 * as good news. A linked tile takes the hover as a whole, with the arrow in its
 * corner darkening to say where it goes.
 */
export function StatTile({
  label,
  value,
  delta,
  deltaLabel,
  formatDelta = (d) => `${d > 0 ? "+" : d < 0 ? "−" : ""}${Math.abs(d).toFixed(1)}%`,
  good = "high",
  trend,
  verdict,
  to,
  href,
  link,
  className = "",
}: StatTileProps) {
  const Link = useLinkComponent(link);
  const up = (delta ?? 0) > 0;
  const better = delta === undefined || delta === 0 ? null : good === "low" ? !up : up;
  const linked = !!(to || href);

  const body = (
    <>
      <div className="flex items-center justify-between gap-3">
        <span className="truncate text-[13px] text-muted">{label}</span>
        <span className="flex shrink-0 items-center gap-2">
          {verdict && (
            <span className="inline-flex h-6 items-center rounded-full bg-gray-100 px-2.5 text-[12px] font-medium text-gray-1000">{verdict}</span>
          )}
          {linked && (
            <span aria-hidden className="text-faint transition-colors duration-150 group-hover:text-gray-1000">
              ↗
            </span>
          )}
        </span>
      </div>
      <div>
        <div className="text-[28px] font-medium leading-[1.1] tracking-[-0.02em] text-gray-1000">{value}</div>
        {(delta !== undefined || trend) && (
          <div className="mt-3 flex items-end justify-between gap-3">
            {delta !== undefined ? (
              <span className="flex items-center gap-1 whitespace-nowrap text-[12px] text-quiet">
                <span aria-hidden className={`inline-block ${up ? "" : "rotate-180"} ${better === false ? "text-quiet" : "text-gray-1000"}`}>
                  ▲
                </span>
                <span className={`font-medium ${better === false ? "text-quiet" : "text-gray-1000"}`}>{formatDelta(delta)}</span>
                {deltaLabel && <span>{deltaLabel}</span>}
                {better !== null && <span className="sr-only">{better ? ", an improvement" : ", a decline"}</span>}
              </span>
            ) : (
              <span />
            )}
            {trend && <Sparkline values={trend} width={72} height={22} good={good} />}
          </div>
        )}
      </div>
    </>
  );

  const cls = `group flex flex-col gap-4 rounded-[14px] p-4 ${linked ? "transition-colors duration-150 ease-geist hover:bg-gray-alpha-100" : ""} ${className}`;
  if (to) return <Link href={to} aria-label={`${label}: details`} className={cls}>{body}</Link>;
  if (href) return <a href={href} aria-label={`${label}: details`} className={cls}>{body}</a>;
  return <div className={cls}>{body}</div>;
}
packages/ui/src/components/data/Sparkline.tsx63 lines
"use client";

import { useId } from "react";
import { ScaleGradient } from "./ScaleGradient.js";
import { scaleColor, scaleT, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";

export type SparklineProps = { values: number[]; width?: number; height?: number; good?: Good; label?: string };

/**
 * A trend small enough to sit beside a number. Stroked on the value scale over
 * its own range, with the current period marked as a dot in its own colour —
 * the only point on it that is a reading rather than a shape. It traces in once,
 * and the dot lands when the line reaches it.
 */
export function Sparkline({ values, width = 96, height = 28, good = "high", label }: SparklineProps) {
  const id = useId().replace(/:/g, "");
  const { ref, drawn, animate } = useDrawIn<SVGSVGElement>();
  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min || 1;
  const step = width / (values.length - 1);
  const pts = values.map((v, i) => [i * step, height - 3 - ((v - min) / span) * (height - 6)] as const);
  const d = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
  const last = pts[pts.length - 1];
  return (
    <svg
      ref={ref}
      width={width}
      height={height}
      viewBox={`-3 0 ${width + 6} ${height}`}
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className="overflow-visible"
    >
      <defs>
        <ScaleGradient id={id} y0={height - 3} y1={3} good={good} />
      </defs>
      <path
        d={d}
        pathLength={1}
        fill="none"
        stroke={`url(#${id})`}
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        style={animate ? { strokeDasharray: 1, strokeDashoffset: drawn ? 0 : 1, transition: "stroke-dashoffset 800ms cubic-bezier(0.16,1,0.3,1)" } : undefined}
      />
      <circle
        cx={last[0]}
        cy={last[1]}
        r="3"
        strokeWidth="2"
        style={{
          fill: scaleColor(scaleT(values[values.length - 1], min, max, good)),
          stroke: "var(--color-background-100)",
          ...(animate ? { opacity: drawn ? 1 : 0, transition: "opacity 200ms ease-out 650ms" } : {}),
        }}
      />
    </svg>
  );
}