Display
Stat Card
A KPI grid — label, big tabular figure and a tone-coded delta per metric — with loading, empty, error and ready driven by one contract.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stat-card.jsonPrompt
Build a React + TypeScript + Tailwind "StatCard" component (lucide-react
ArrowUp / ArrowDown / Minus / ChartColumn) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
items: { id, label, value (number | string), unit?, delta? (percent),
trend: "up" | "down" | "flat", hint? }[] }.
- Component props = z.infer of the schema plus presentation-only extras:
columns?: 1 | 2 | 3 | 4 (default 4), onRetry?: () => void, className.
- value accepts a raw number (the component formats it with
Intl.NumberFormat) or an already-formatted string like "4m 12s" / "1.2M",
so hosts that pre-format server-side don't have to fight the formatter.
- trend is separate from delta's sign: trend picks the icon and the tone,
delta is only the printed magnitude. Metrics where "down is good" flip one
field, not the data.
Behavior
- Four first-class branches, never an afterthought &&:
loading renders `columns` skeleton cards using the exact same card box and
the same three rows (label bar / figure bar / delta bar) as ready, so the
row's height never jumps when data lands; the grid carries aria-busy and the
skeleton innards are aria-hidden.
empty renders one full-width dashed panel: a muted chart glyph plus a single
guiding sentence — no fake zeros.
error renders a destructive headline, a muted explanation and a "Try again"
button that exists only when onRetry was passed; the component never fetches
or retries on its own.
ready renders the metric grid.
- Each metric card: label on top, the figure at text-2xl semibold with an
optional unit on the same baseline, then a delta chip and an optional hint
on one wrapping row. Cards with neither delta nor hint simply drop that row.
- Delta chip = arrow icon + signed percentage to one decimal. The icon is
aria-hidden and an sr-only word ("up" / "down" / "flat") is read before the
number, so the direction survives without color.
- Every figure and percentage uses tabular-nums so numbers in adjacent cards
line up and a live-updating value doesn't wobble.
- Responsive: the grid collapses to one column on narrow screens regardless of
`columns`, so cards never squeeze below readable width.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground + border rounded-xl for
the card, text-muted-foreground for labels, hints and the empty state,
bg-muted for skeleton bars, text-destructive for the error headline and the
falling delta, text-chart-2 for the rising delta, text-muted-foreground for
flat. No hardcoded colors, so dark mode and any host palette come free.
- Column classes come from a small static lookup keyed by `columns` — never a
template-string class, which Tailwind cannot see at build time.
- cn() merges the consumer className onto whichever branch is rendering, so a
host can widen gaps or cap the width from the outside.
- The retry button carries focus-visible:ring-2 focus-visible:ring-ring and a
hover background; it is the only interactive element in the component.
Customization levers
- Density: card padding (p-4), inner gap (gap-1.5) and figure size (text-2xl)
are one token each — shrink for a 6-up admin strip, grow for a 2-up hero.
- Columns: the lookup table is the whole responsive story; add a 6-key or
change the breakpoints there without touching the render.
- Tone mapping: one TREND table maps up/down/flat to icon + text token — invert
it for "lower is better" metrics (churn, latency, cost), or move both tones
onto chart tokens when primary is reserved for CTAs.
- Delta presentation: swap the signed percent for absolute deltas, or wrap the
chip in a tinted pill via color-mix(in oklab, var(--chart-2) 12%, transparent).
- Sub-blocks: unit, delta and hint are all optional in the contract — drop them
for a bare number wall, or extend the item with an icon/href field and render
it in the label row.
- Skeleton count: loading mirrors `columns`; feed it the last known item count
instead if you cache the previous payload.Concepts
- Four states from one contract — a KPI row is fed by an endpoint that can be slow, empty or down;
statusmakes those three paths real branches besideready, so nothing renders as a lonely0while the request is still in flight. - Skeleton mirrors anatomy — loading reuses the ready card's box and its three-row silhouette, so the header row occupies its final height from the first paint and the page below never jumps.
- Trend is not the sign —
trenddrives icon and tone whiledeltaonly supplies the number, which is what lets "churn down 2.1%" read as good news by remapping a single table entry. - Direction without color — the arrow is decorative and an
sr-onlyword carries up/down/flat, so the delta stays legible to screen readers and to anyone who can't separate the green from the red. - Tabular figures —
tabular-numsfixes digit width so adjacent cards align on the decimal and a value ticking upward doesn't shuffle its own layout. - Retry belongs to the caller — the error branch renders a button only when
onRetryexists; the component owns the presentation of failure and never the fetch.