Skip to content
Andrew SmithSolutions
← Component library

Stat tile

Data display

A metric, its change and an optional sparkline — with the direction written in the words rather than left to an arrow and a colour.

Metrictabular-numsNot colour-onlyCSS only — no dependency

Preview

Booked revenue

£48,200

Up 12% on last quarter

Controls

Notes

The change reads "Up 12% on last quarter" and the arrow is decorative. An arrow plus a colour is exactly the failure SC 1.4.1 describes: nothing for a screen reader, and nothing for a reader who cannot separate the two colours.

Direction is not sentiment. Churn going up is not good news, so the accent is opt-in through a separate emphasis flag rather than wired to "up".

The number is tabular-nums, so a row of these does not jitter as the digits change.

The sparkline is aria-hidden and has no accessible values. It must never carry information the number and the change do not already state.

Source

Show
import { cn } from "@/lib/cn";

/**
 * A metric tile.
 *
 * The direction is in the WORD — "Up 12% on last quarter" — and the arrow is
 * decoration. An arrow plus a colour is exactly the failure SC 1.4.1 describes:
 * nothing for a screen reader, and nothing for the readers who cannot tell the
 * two colours apart.
 *
 * Direction is also not sentiment. Churn going up is not good news, so the
 * accent is opt-in through `emphasis` rather than wired to "up".
 */
const directions = {
  up: { glyph: "↑", word: "Up", points: "0,26 14,21 28,23 42,15 56,17 70,9 84,11 98,3" },
  down: { glyph: "↓", word: "Down", points: "0,4 14,9 28,7 42,15 56,13 70,21 84,19 98,27" },
  flat: { glyph: "→", word: "Flat", points: "0,16 14,14 28,17 42,15 56,16 70,14 84,16 98,15" },
} as const;

export type StatDirection = keyof typeof directions;

export function Stat({
  label,
  value,
  delta,
  direction = "up",
  emphasis = false,
  spark = true,
  className,
}: {
  label: string;
  value: string;
  /** The change, without its direction — "12% on last quarter". */
  delta?: string;
  direction?: StatDirection;
  /** Accent the change. Reserve it for "look at this", not for "the number rose". */
  emphasis?: boolean;
  spark?: boolean;
  className?: string;
}) {
  const trend = directions[direction];

  return (
    <div
      className={cn(
        "flex flex-col rounded-xl border border-rule bg-paper-raised p-6",
        className,
      )}
    >
      <p className="eyebrow">{label}</p>

      {/* tabular-nums so a column of these does not jitter as digits change. */}
      <p className="mt-4 text-4xl leading-none font-medium tabular-nums text-ink">{value}</p>

      {delta && (
        <p className={cn("mt-3 text-[13px]", emphasis ? "text-accent" : "text-ink-muted")}>
          <span aria-hidden="true">{trend.glyph} </span>
          {trend.word} {delta}
        </p>
      )}

      {spark && (
        // Decoration with no accessible values. It must never carry something
        // the number and the delta do not already say.
        <svg
          viewBox="0 0 98 30"
          aria-hidden="true"
          preserveAspectRatio="none"
          className={cn("mt-5 h-8 w-full", emphasis ? "text-accent" : "text-ink-faint")}
        >
          <polyline
            points={trend.points}
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            vectorEffect="non-scaling-stroke"
          />
        </svg>
      )}
    </div>
  );
}

export default function Demo({
  label = "Booked revenue",
  value = "£48,200",
  delta = "12% on last quarter",
  direction = "up",
  emphasis = false,
  spark = true,
}: {
  label?: string;
  value?: string;
  delta?: string;
  direction?: StatDirection;
  emphasis?: boolean;
  spark?: boolean;
}) {
  return (
    <Stat
      className="w-full max-w-[16rem]"
      label={label}
      value={value}
      delta={delta}
      direction={direction}
      emphasis={emphasis}
      spark={spark}
    />
  );
}

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.