StatCard

One metric, its change, and an optional sparkline.

Projects published
+12%
1,284

Usage

import { StatCard } from "@hoplallc/ui/stat-card";
<StatCard.Root>
	<StatCard.Header>
		<StatCard.Label>Projects published</StatCard.Label>
		<StatCard.Delta trend="up">+12%</StatCard.Delta>
	</StatCard.Header>
	<StatCard.Value>1,284</StatCard.Value>
</StatCard.Root>

The label and the delta share the header row; the value sits under them on its own line, so a row of cards lines its numbers up whether or not each one has a delta to show.

trend picks the arrow and the colour; it is separate from the text because a falling number is not always bad — cancellations going down is a down trend you would want to read as good.

Recipes

A sparkline for the shape of the number

Projects published
+12%
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38

StatCard.Chart is a full-bleed slot pinned to the bottom edge: it cancels the card's bottom padding and the card clips the chart to its rounded corners. Give the <Chart> a height, hide its axes and drop the margins, since a sparkline has no axes to make room for.

import { areaY, defineChart } from "@hoplallc/chart";
import { Chart } from "@hoplallc/chart/react";
 
<StatCard.Chart>
	<Chart
		ariaLabel="Projects published"
		height={64}
		legend={false}
		definition={defineChart({
			x: { axis: false },
			y: { axis: false, grid: false },
			margin: { top: 4, right: 0, bottom: 0, left: 0 },
			marks: [areaY(data, { x: "month", y: "projects", line: true })],
		})}
	/>
</StatCard.Chart>

The cursor, the tooltip and the keyboard work in a 64-pixel-tall chart exactly as they do in a full-size one, so a sparkline can still be read value by value.

The three sparkline shapes

Projects published
+12%
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Projects published
+12%
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Projects published
+12%
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38

An area for a volume that accumulates, a line for a rate, bars for counts per period. Bars need a band axis to sit in; a line or an area wants point.

Falling numbers that are good news

Cancellations
-45%
17
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Cancellations
monthCancellations
January31
February27
March24
April22
May19
June17
<StatCard.Delta trend="down" className="text-success">
	-45%
</StatCard.Delta>

trend keeps the arrow pointing down while the colour says the metric is moving the right way.

Metric status against an objective

Fill rate
+4%
92%
Target: 90%
Bookings confirmed
-8%
1,102
Target: 1,250
Cancellations
+45%
17
Target: under 10
<StatCard.Root status="alerting">
	<StatCard.Header>
		<StatCard.Label>Cancellations</StatCard.Label>
	</StatCard.Header>
	<StatCard.Value>17</StatCard.Value>
	<StatCard.Target>Target: under 10</StatCard.Target>
</StatCard.Root>

status sets data-status on the root and tints the frame with the semantic tokens — alerting destructive, below-objective warning, on-track the default frame. The tint goes on the frame, not the value, so the number stays readable and a scan of the grid shows where the fires are.

StatCard.Target is a quiet line under the value for the objective the tint refers to. The copy is yours — the component only sets the muted small type.

Loading and empty

Awaiting data
—
New this week
0
No data yet

ChartSkeleton and ChartEmpty (from @hoplallc/chart/react) slot straight into StatCard.Chart. Pad the slot back (px-4, plus pb-4 for the empty state) so the placeholder sits inside the card instead of bleeding to its edges like a real chart.

Compact, for dense dashboards

Published
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Booked
1,102
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Cancelled
17
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Fill rate
92%
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38

size="sm" on the root tightens the padding and the gaps; the anatomy is unchanged.

A number that counts to its new value

Projects published
1,284

Give StatCard.Value a value instead of children and it springs from the old number to the new one when the data refreshes, with @hoplallc/motion. format takes an Intl.NumberFormat or a function. Each frame writes the text directly, so the card doesn't re-render while it counts. A screen reader hears only the final value, the server renders it, and reduced motion jumps straight to it.

<StatCard.Value value={projects} format={new Intl.NumberFormat("en-GB")} />

A KPI strip

Projects published
+12%
1,284
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38
Cancellations
-45%
17
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Cancellations
monthCancellations
January31
February27
March24
April22
May19
June17
Bookings confirmed
0%
1,102
Flèches pour parcourir les valeurs, Entrée pour épingler, Échap pour quitter.
Données : Projects published
monthProjects published
January24
February31
March28
April35
May42
June38

API

StatCard.Root

PropTypeDefault
size"default" | "sm"—
status"alerting" | "below-objective" | "on-track"—Health of the metric against its objective. The tint goes on the frame, not the value, so the number stays readable and a scan of the grid shows where the fires are: alerting renders a destructive frame, below-objective a warning one, on-track keeps the default frame.
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.
renderComponentRenderFn<HTMLProps, {}> | ReactElement<unknown, string | JSXElementConstructor<any>>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Plus the DOM props of the element it renders. Source: stat-card/components/stat-card-root.tsx.

StatCard.Chart

PropTypeDefault
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.

Plus every <div> prop. Source: stat-card/components/stat-card-chart.tsx.

StatCard.Delta

PropTypeDefault
trend"down" | "neutral" | "up""neutral"Direction of the change: up renders success, down destructive, neutral muted. Set it by what the movement means, not by its sign — churn going up is not a success.
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.

Plus every <div> prop. Source: stat-card/components/stat-card-delta.tsx.

StatCard.Header

PropTypeDefault
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.
renderComponentRenderFn<HTMLProps, {}> | ReactElement<unknown, string | JSXElementConstructor<any>>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Plus every <div> prop. Source: stat-card/components/stat-card-header.tsx.

StatCard.Label

PropTypeDefault
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.
renderComponentRenderFn<HTMLProps, {}> | ReactElement<unknown, string | JSXElementConstructor<any>>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Plus every <div> prop. Source: stat-card/components/stat-card-label.tsx.

StatCard.Target

PropTypeDefault
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.
renderComponentRenderFn<HTMLProps, {}> | ReactElement<unknown, string | JSXElementConstructor<any>>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Plus every <div> prop. Source: stat-card/components/stat-card-target.tsx.

StatCard.Value

PropTypeDefault
formatStatCardFormat—How value reads: an Intl.NumberFormat or a function. Defaults to the target's own decimals.
valuenumber—A number to show instead of children: it springs from the previous value when it changes.
exitExit—Inside a <Presence>: plays this child's way out before it unmounts.
renderComponentRenderFn<HTMLProps, {}> | ReactElement<unknown, string | JSXElementConstructor<any>>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Plus every <div> prop. Source: stat-card/components/stat-card-value.tsx.