Composites

Stat Card

A KPI tile: a label, one large number, and the movement since last period. It adds an optional icon chip, a sparkline slot and a footer caption, and it is entirely static — no state, no effects, so it renders straight from a Server Component.

The interesting decision on this component is not the layout, it is the colour of the delta line. StatCard refuses to colour a trend from its arrow alone, because a rising number is not always good news. See Delta direction and polarity.

Basic usage

label and value are the only required props; everything else is an opt-in row of the card. Note that icon takes the component itself — icon={Banknote}, not icon={<Banknote />}.

Monthly recurring revenue

$48,290

+12.5%vs. last month

Synced 2 minutes ago

Anatomy

Four stacked rows — the header, then three optional ones that each disappear when you leave the matching prop out. The card surface is fixed at rounded-xl border border-border bg-card p-5 with no shadow — on a row of four tiles a card shadow reads as noise, so the tier drops it deliberately.

  • Header row — label and value on the left, the icon chip on the right.
  • Delta line — arrow, movement, caption. It sits outside the two-column header on purpose: inside the left column it was bounded by a 147px column on a 229px card, so “+3 tasks vs. last week” broke into two ragged stacks. Full-width, it fits on one line.
  • Sparkline — a 40px strip you fill yourself.
  • Footer — a caption below a top rule, for freshness or provenance.

Revenue

$48,290

+12.5%vs. last month

Active users

1,284

+38this week

Avg. response time

1m 42s

-14svs. last week

Overdue tickets

18

+6vs. last week

Icon tones

tone paints the 28px icon chip and nothing else — not the number, not the card. Treat it as a way of telling four tiles apart at a glance, not as a status signal: the tone is invisible to a screen reader (the chip is aria-hidden) and it does nothing at all if you pass no icon. app follows --app-accent, the per-product accent the shell sets; with no product accent in play it falls back to the brand primary.

Records

1,284

tone="default"

Deployments

42

tone="primary"

Revenue

$48,290

tone="success"

Overdue

18

tone="warning"

Incidents

3

tone="destructive"

Response time

1m 42s

tone="info"

Seats in use

312

tone="app"

Delta direction and polarity

The delta colour is direction × polarity, never direction on its own. Overdue tickets going up is bad; churn going down is good; headcount moving is neither. Colouring by the arrow would confidently tell the reader the opposite of the truth on every inverted metric, so polarity exists to say which way is up.

polaritydirection: updirection: downdirection: flat
positive-up (default)success-inkdestructive-inkmuted-foreground
positive-downdestructive-inksuccess-inkmuted-foreground
neutralmuted-foregroundmuted-foregroundmuted-foreground

Revenue

$48,290

+12.5%vs. last month

up · positive-up → good

Overdue tickets

18

+6vs. last week

up · positive-down → bad

Churn rate

2.1%

-0.4 ppvs. last month

down · positive-down → good

Headcount

312

+4this quarter

neutral → always muted

Open tasks

46

No changevs. last week

flat → always muted

Two smaller rules fall out of the same function. direction: 'flat' is muted whatever the polarity — there is no good or bad way to not move. And delta.label is always rendered muted and non-bold even when the number beside it is red or green: the caption is context, not part of the signal.

Sparkline and footer

sparkline is a bare ReactNode rendered inside a 40px-tall strip. The package ships charts at @comitor/ui/chart but StatCard does not reach for them, so a card costs you no charting dependency you were not already paying for. The demo below is eight points of inline SVG using currentColor, tinted with a role token.

Monthly recurring revenue

$48,290

+12.5%vs. last month

Rolling 30 days · updated hourly

Whatever you put in there is decoration unless you make it otherwise: mark it aria-hidden and let the value and delta carry the meaning, or give it a real accessible name and a text alternative. A shape that is the only place a fact appears is a fact most of your readers do not get.

Loading

loading replaces the value with a Skeleton and, importantly, suppresses the delta line too — otherwise last render's green “+12.5%” sits confidently under a grey placeholder. Label, icon, sparkline and footer stay, so the card keeps its shape — but suppressing the delta takes that row with it, so a card with a delta grows by one row when the data lands. Reserve the space yourself if the grid must not reflow.

Monthly recurring revenue

Synced 2 minutes ago

statCardIconVariants

The chip is published as its own cva recipe: statCardIconVariants({ tone }) returns flex size-7 shrink-0 items-center justify-center rounded-lg plus the tone pair. Use it when something outside a stat card needs the identical chip — a list row, an activity feed, a summary line — so the two never drift apart.

toneclasses
defaultbg-muted text-muted-foreground
primarybg-primary/20 text-primary-ink
successbg-success/15 text-success-ink
warningbg-warning/20 text-warning-ink
destructivebg-destructive/15 text-destructive-ink
infobg-info/15 text-info-ink
appbg-app-accent/10 text-app-accent-ink

Every tone is a tint background + ink foreground pair — bg-success/15 text-success-ink, not text-success. That is the three-role colour contract doing real work: an icon is non-text content, so it owes 3:1 against the tint it sits on, and the fill token used as a foreground only manages 2.69:1 in light. The -ink step is the same hue measured for exactly this job.

StatCard props

PropTypeDefaultDescription
labelrequiredReactNodeWhat the number counts, e.g. "Open tickets". Rendered 12px, medium, muted — and truncated to one line, so keep it short.
valuerequiredReactNodeThe metric itself. Rendered 24px bold with tabular-nums so a live-updating row of cards does not jitter as digits change width.
iconIconComponentThe icon COMPONENT, not an element: icon={Banknote}, never icon={<Banknote />}. Rendered at 14px inside the tone chip and marked aria-hidden.
tone'default' | 'primary' | 'success' | 'warning' | 'destructive' | 'info' | 'app''default'Colour of the icon chip only. Has no effect when no icon is passed.
deltaStatCardDeltaThe movement line under the value: { value, direction, polarity?, label? }. Hidden while loading is true.
sparklineReactNodeFree slot rendered in a 40px-tall strip below the delta. The package imposes no charting library.
footerReactNodeCaption below a top rule — freshness, source, or a link out.
loadingbooleanfalseSwaps the value for a 28×96px Skeleton and suppresses the delta line, so a stale trend never sits under a placeholder number.
classNamestringMerged after the card classes (rounded-xl border border-border bg-card p-5), so what you pass wins.
...restOmit<ComponentProps<'div'>, 'title'>Forwarded to the wrapper div. Note the omit: the native title attribute is not accepted.

IconComponent is the tier-wide icon type: ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>. It takes a component rather than an element because a Lucide icon is a forwardRef object, not a function — nothing at runtime can tell it apart from an already-rendered element, so the type refuses the ambiguity up front and the compiler catches the mistake instead of the page rendering blank.

StatCardDelta fields

PropTypeDefaultDescription
valuerequiredReactNodeThe movement as you want it read: "+12.5%", "-0.4 pp", "3 tasks". Include the sign — the arrow is aria-hidden, so the sign is what assistive tech gets.
directionrequired'up' | 'down' | 'flat'Picks the icon: ArrowUpRight, ArrowDownRight or Minus. Together with polarity it also picks the colour.
polarity'positive-up' | 'positive-down' | 'neutral''positive-up'Whether rising is good news. positive-down for metrics that should fall (churn, latency, overdue work); neutral for movement that is neither good nor bad.
labelReactNodeCaption after the number — "vs. last week". Always rendered muted and non-bold, whatever the delta colour is.

Putting a row of them together

StatCard renders a plain <div> with data-slot="stat-card" and no ARIA role — so four of them in a grid are, to a screen reader, four unrelated paragraphs. Give the group the structure yourself: a list for the items, a heading for the period they cover.

kpis.tsx
import { StatCard, type StatCardDelta } from '@comitor/ui'

interface Kpi {
  id: string
  label: string
  value: string
  delta?: StatCardDelta
}

// A row of KPIs is a list. Say so, and give the region a heading — the cards
// themselves are plain <div>s with no role, by design.
export function Kpis({ items }: { items: Kpi[] }) {
  return (
    <section aria-labelledby="kpi-heading">
      <h2 id="kpi-heading" className="text-sm font-semibold text-foreground mb-3">
        This month
      </h2>
      <ul className="grid list-none gap-4 sm:grid-cols-2 lg:grid-cols-4">
        {items.map((item) => (
          <li key={item.id}>
            <StatCard label={item.label} value={item.value} delta={item.delta} />
          </li>
        ))}
      </ul>
    </section>
  )
}

Accessibility

  • Both icons are hidden. The tone chip carries aria-hidden="true" and so does the delta arrow. Neither is announced, which means neither may be the only place a fact appears — write the sign into delta.value (“+6”, not “6”) so direction survives without colour or shape, per WCAG 1.4.1.
  • Delta text clears AA in both themes. The line is 12px on bg-card, so it owes 4.5:1. Using fill tokens as text fails on one side or the other — text-success is 3.13:1 in light, text-destructive is 3.53:1 in dark. The ink steps pass both: success 6.52:1 (light) / 10.19:1 (dark), destructive 7.15:1 / 6.42:1, muted-foreground 5.23:1 / 5.35:1.
  • Every icon chip clears the non-text 3:1 except one. Measured on the tint already mixed with --card: success 5.60:1 (L) / 7.44:1 (D), info 5.54 / 7.12, warning 5.93 / 7.19, destructive 5.76 / 5.71, default 4.89 / 4.86, app 6.14 / 9.61.
  • tone="primary" is the exception, and it is known. Its chip is bg-primary/20 with text-primary-ink, and the default palette resolves --primary-ink straight back to --primary — gold on a gold wash, 1.64:1, under the 3:1 bar. It measures 5.77:1 only under data-contrast="high", where --primary-ink is a darker gold, and that is the figure this page used to quote for both palettes. Reach for another tone when the colour is carrying the meaning; keep primary for emphasis the label already spells out. The same reasoning is why FormSection leaves primary out of its icon tint entirely.
  • The card has no role and no landmark. That is deliberate — a KPI tile is content, not a widget. Wrap a set in a <ul> under a heading so the count and the boundaries are announced, and never rely on visual proximity to say “these four belong together”.
  • Nothing here is focusable. There is no click handler, no tabIndex, no hover affordance. If a tile should drill through to a report, put a real link inside it — an onClick on the wrapper div gives keyboard users nothing.
  • Live regions are your call. The card re-renders silently when the number changes. A dashboard that updates on a socket should own the announcement — usually one polite live region summarising the change, not aria-live on every tile, which turns a quiet dashboard into a stream of interruptions.
  • The value is tabular-nums. Not an ARIA matter, but a cognitive one: fixed-width digits stop the number twitching sideways on every poll, which is what makes a live figure readable at all.