Components / Stat Card

Stat Card

The summary row that opens an area page, a dashboard, or a post detail sheet: a run of KPIs, each a label, a formatted value, and optionally what it was doing over a comparison window.

import { StatCard, StatCardGrid } from '@layers/ui/stat-card'

Rendered stock

House rules

  • Wrap the run in StatCardGrid, always. Stat tiles are presented in groups of three to eight and the product has no single one; the grid owns the plate, the hairlines and the column count, so a bare row of StatCards is missing the surface, not just the wrapper.
  • Never reach for Grid to lay these out. Grid is fixed 1-4 columns with no responsive step, so a four-column Grid is still four columns at 320px. StatCardGrid picks its columns from the container width AND the group size, so five tiles land 3+2 rather than 4+1.
  • There is no icon slot. A glyph in front of every label turns a summary row into a row of badges and competes with the numbers, which are the actual reading. The tooltip mark is the one exception, because it is an affordance rather than decoration.
  • The tile is a body (label, then value) with an optional footer under it holding the comparison pill and the subtitle. Inside the grid the two bands are shared subgrid rows, so the values land on one line and the pills on one baseline across the row — even when some tiles have no footer at all, which is a legitimate state (subtitles are often conditional): a footerless tile leaves its slice of the footer band as bare plate instead of letting its number sink to the cell floor.
  • Pass raw numbers; the tile formats them (8200 renders as 8.2K). Preformat only string values like currency.
  • A delta needs a comparison to mean anything. comparison is the window's length in DAYS and renders as "vs last 30d" — shorter than a period name and more precise, since the window the caller queried is a day count it already knows while "last month" is a claim about a calendar the arithmetic never made. Ignored when there is no delta.
  • Show the lift, never the lift beside the raw prior value. There is no previousValue prop: the percentage IS the comparison, and the count it was computed from is arithmetic, not a reading. The tile formats the percentage itself — one decimal under 10%, rounded above it, digits grouped past 1000 — so no call site does its own toFixed.
  • Every string on this tile is as short as it can be and still be true. It is read at a glance, eight at a time, in the narrowest column on the page — anything that reads as a sentence is too long.
  • Set deltaInverted on metrics where a drop is good, like cost per view or churn, so the delta colors read correctly.
  • action puts ONE compact control that belongs to this tile in its top-right corner, on the label line: a window picker that re-scopes the value, a Partial badge that qualifies it. Never a portal or an absolutely positioned overlay; the label gives up the width and wraps.
  • tone colours the VALUE against a goal the caller holds: tone="positive" (success ink) at or above a target, tone="warning" below it, tone="negative" (destructive ink) only for a hard floor that was breached. It is separate from the delta colour, which reads the change against the previous window.
  • subtitle takes a node when the context line carries its own affordance, such as a warning mark with a tooltip explaining why the value undercounts. Plain strings stay the common case.
  • There is no chart in the tile. A sparkline was built and cut on 2026-08-05: a summary row is read at a glance and a second figure in each tile is a second thing to read. The comparison line carries the window; a surface that needs the shape of it wants a chart section beneath the row, not eight glyphs inside it.
  • A missing value renders as a dash placeholder; never substitute a fake zero.
  • Loading goes through isLoading, which swaps the value and comparison line for shape-matched Skeletons so the tile keeps its height — and when the label is not known yet (data-derived labels), pass label="" and the label row shimmers too. No hand-rolled loading states.

See also