Metric Card
A dashboard card for one number: value, goal, delta badge, and its recent trend.
Installation
Usage
import { MetricCard, Sparkline } from "@/components/ui/metric-card"Examples
Default
Title, value, goal, distance left, and the trend under it.
A row of them
Each card picks the chart shape its metric wants: area for revenue, bars for a count per period, a line for a rate.
With a goal bar
Children join the value block on the bind rung (8px), so a Progress bar reads as part of the number. The chart stays a peer below at 16px.
No trend
Leave trend off and the card is just the number. Nothing collapses or shifts.
Chart types
bar for a count per period, line for a trend, area for volume under a trend. Line and area mark the last point with a dot; bars put the last one at full accent and step the rest back.
Chart sizes
24, 40, and 64 pixels tall. Width always fills the container, and a bar is always half its column, so the density holds at any width and any number of points.
The chart on its own
Outside a card, where a card would be the wrong box: one row, one series, the number on the end.
No data
An empty series still renders at full height with a hairline on the baseline, so the card does not jump when the numbers arrive.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | - | The card header label. |
| value | ReactNode | - | The headline number. |
| goal | ReactNode | - | The line under the value, e.g. "Goal: 262" or "vs. last month". |
| delta | { value: number; label?: string } | - | Distance left to the goal, rendered as "79 to go". Flips to "Goal reached!" at zero or below. |
| unit | string | - | Appended to goal, e.g. "lb". |
| badge | ReactNode | - | Sits inline beside the value. Pass an element: badge={<Badge>+12%</Badge>}. |
| action | ReactNode | - | Header right slot, for a Kebab or an icon Button. |
| link | { label: string; onClick: () => void } | - | Header right slot as a text link. Ignored when action is set. |
| trend | { data: number[]; type?; size?; label? } | - | Recent history, drawn under the value by Sparkline. Omit it for a card with no chart. |
| children | ReactNode | - | Extra content inside the value block, e.g. a Progress bar. Binds to the value at 8px. |
| dataSparkline | number[] | - | The series, oldest first. An empty array renders the no-data baseline at full height. |
| typeSparkline | "bar" | "line" | "area" | "bar" | bar measures from zero, because a bar's length is the value. line and area measure the min-to-max band, because a trend is about the shape of the change. A series that crosses zero belongs in line; bars clamp negatives to the baseline. |
| sizeSparkline | "sm" | "default" | "lg" | "default" | Height: 24, 40, or 64 pixels. Width always fills the container. |
| labelSparkline | string | - | Accessible name. Generated from the data when omitted, in the form "52 points, 62 to 133, trending up". |
| classNameSparkline | string | - | Merged onto the root. Set the text color here to retarget every mark: className="text-destructive-500". |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import {
Card,
CardContent,
DashboardCardHeader,
MetricCardValue,
} from "@/registry/ui/card"
import { Stack } from "@/registry/ui/stack"
type SparklineType = "bar" | "line" | "area"
type SparklineSize = "sm" | "default" | "lg"
const SIZE_CLASS: Record<SparklineSize, string> = {
sm: "h-6",
default: "h-10",
lg: "h-16",
}
const SIZE_HEIGHT: Record<SparklineSize, number> = {
sm: 24,
default: 40,
lg: 64,
}
// The bar takes 60% of its column and the gap takes the rest, at every width.
// The ratio is the constant here, not a pixel: the chart fills its container,
// so the bar grows and shrinks with the card and a wide card gets wider bars
// rather than the same thin ones adrift in their gutters.
//
// An earlier version capped the pitch and centered a narrower chart in a wide
// box, which is why that box used to render a short chart floating in the
// middle of it. The failure that cap was written for is a different one: it is
// capping the BAR while the gap keeps growing, which does read as a dotted
// line. Scaling both together has no such break, so nothing needs a ceiling.
const BAR_RATIO = 0.6
// The shortest a bar can be, so a zero in the middle of a series is still a
// mark rather than a gap in the row.
const MIN_BAR_HEIGHT = 2
// Bars are DRAWN, not laid out, and that is the whole reason this is an SVG
// instead of the flex row it started as.
//
// A row of CSS boxes cannot be evenly spaced at an arbitrary width. Thirty
// columns in a 203px card is a 6.77px pitch, and a browser paints a box by
// snapping its edges to whole device pixels, so those columns land 13 and 14
// device pixels apart in whatever pattern the fraction falls in. The bars
// themselves come out pixel-identical, which is what makes it so hard to read:
// measured, every bar was exactly 10 device pixels, and the unevenness was
// entirely in the 3px-vs-4px GAPS between them. The eye reads that whitespace
// as part of the rhythm, so a bar with a tight gap on each side looks fatter
// than its neighbor and the row looks like it has the wrong bars in it.
//
// SVG geometry is not snapped, it is ANTIALIASED. A bar at a fractional
// position spreads its edge across two pixels instead of jumping to the next
// one, so every bar carries the same ink and every gap carries the same ink at
// any container width. The cost is that an edge is soft rather than crisp,
// which is the trade this makes on purpose.
//
// No viewBox, deliberately: user units are then CSS pixels, so a width is a
// real width and `rx` paints a true half-circle end. The line and area shapes
// below take the opposite deal (a 100-wide viewBox stretched with
// preserveAspectRatio="none") because a path has to be positioned in
// proportional units, and they pay for it with `vector-effect` on every stroke.
// The SVG viewBox is 100 wide and stretches to the container (preserveAspectRatio
// "none"), so horizontal units are percentages and vertical units are real pixels.
// Every stroke carries vector-effect="non-scaling-stroke", which is what keeps the
// line 2.5px and the end dot round no matter how wide the card gets.
const VIEW_WIDTH = 100
// 2.5px, not a hairline. The line sits beside 14px text and is the subject of
// the row it is in; at 1.5 it was the lightest thing in that row, and 3 was
// heavy. It is set in SCREEN pixels, not user units, because of the
// non-scaling-stroke above, so this number is literally what renders at any
// card width - and it is CSS pixels, so a screenshot off a 2x display measures
// double. Half a pixel is fine: a curve is antialiased anyway, so there is no
// hairline to snap and no text to blur.
const STROKE_WIDTH = 2.5
// Twice the stroke. Tie the two together: a dot much past 2x reads as a blob
// stuck on the end rather than the last reading, and much under it disappears
// into the line.
const DOT_SIZE = STROKE_WIDTH * 2
// Vertical inset so a peak's stroke and the end dot are not sheared off by the
// top and bottom edges. Horizontal bleed is handled by letting the SVG overflow.
const INSET = DOT_SIZE / 2
function formatValue(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function describe(data: number[]) {
const first = data[0]
const last = data[data.length - 1]
const direction = last > first ? "trending up" : last < first ? "trending down" : "flat"
return `${data.length} points, ${formatValue(first)} to ${formatValue(last)}, ${direction}`
}
function Sparkline({
data,
type = "bar",
size = "default",
label,
className,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
/** The series, oldest first. */
data: number[]
/** `bar` for a count per period, `line` for a trend, `area` for volume under a trend. */
type?: SparklineType
/** 24px / 40px / 64px tall. */
size?: SparklineSize
/** Accessible name. Generated from the data when omitted. */
label?: string
}) {
const height = SIZE_HEIGHT[size]
// No data still renders the full height, so a card does not jump when the
// series arrives. A hairline on the baseline reads as "nothing yet".
if (data.length === 0) {
return (
<div
data-slot="sparkline"
data-empty=""
className={cn("flex w-full items-end", SIZE_CLASS[size], className)}
role="img"
aria-label={label ?? "No data"}
{...props}
>
{/* 2px against the line's 2.5, on purpose, and NOT tied to STROKE_WIDTH.
Matching the numbers does not match the weight: this bar is dead
straight, so every pixel of it is full opacity, while a curve of the
same width spreads across partially transparent edge pixels. At an
equal 2.5 the baseline read visibly heavier than the line it stands
in for. Optical, so it is corrected by eye, not by arithmetic. */}
<div className="bg-border h-0.5 w-full rounded-full" />
</div>
)
}
const min = Math.min(...data)
const max = Math.max(...data)
const root = (children: React.ReactNode) => (
<div
data-slot="sparkline"
data-type={type}
className={cn("text-accent w-full", SIZE_CLASS[size], className)}
role="img"
aria-label={label ?? describe(data)}
{...props}
>
{children}
</div>
)
if (type === "bar") {
// A bar's LENGTH is the value, so bars measure from zero, never from the
// series low: a band-scaled bar chart says a 98 is a fraction of a 100.
// Values below zero clamp to the baseline; a series that crosses zero
// wants `type="line"`, whose band scale is the honest one for it.
const span = max > 0 ? max : 1
// One column per point, and the bar sits centered in it: 60% wide, so 20%
// of the pitch is left over on each side. Both are percentages of the SAME
// rendered width, which is what keeps every bar and every gap equal however
// wide the card is.
const pitch = 100 / data.length
const bar = (value: number, i: number) => {
const length = Math.max(
MIN_BAR_HEIGHT,
(Math.max(0, Math.min(value, max)) / span) * height
)
return (
// A NESTED VIEWPORT PER BAR, which is the only way to round one whose
// width is a percentage.
//
// CSS and SVG disagree about an oversized radius. `rounded-full` scales
// both radii by ONE factor until they fit, so the ends stay circular. An
// SVG rect clamps rx to half its width and ry to half its height
// SEPARATELY, so the same trick paints a 2.5 x 20 ellipse and a row of
// them reads as a line of eyes. Naming a real radius instead is no help:
// the bar's width is a percentage of the card, and nothing knows what
// that is in pixels at render time.
//
// Inside its own <svg>, 50% means half the BAR. rx resolves to half the
// width, ry follows rx, and each lands under its own clamp, so the ends
// are circular at any card width and a near-zero value still flattens
// to the same lozenge the CSS box drew.
<svg
key={i}
x={`${(i + (1 - BAR_RATIO) / 2) * pitch}%`}
y={height - length}
width={`${BAR_RATIO * pitch}%`}
height={length}
>
<rect width="100%" height="100%" rx="50%" fill="currentColor" />
</svg>
)
}
return root(
<svg className="block h-full w-full" aria-hidden="true">
{/* The most recent bar is the one the eye should land on, so the rest
step back. Opacity, not a lighter token, so both strengths stay the
same color as a consumer's own `--accent`.
It is set on the GROUP rather than on each bar, so the muted bars
composite once. Per-bar opacity blends twice anywhere two of them
touch, which paints a seam down a dense row. */}
<g opacity={0.6}>{data.slice(0, -1).map((value, i) => bar(value, i))}</g>
{bar(data[data.length - 1], data.length - 1)}
</svg>
)
}
// Line and area read the min-to-max BAND, not zero: a trend is about the
// shape of the change, and a zero-based line flattens every real series
// whose values sit far from zero.
const span = max - min || 1
const usable = height - INSET * 2
const x = (i: number) => (data.length === 1 ? VIEW_WIDTH / 2 : (i / (data.length - 1)) * VIEW_WIDTH)
const y = (value: number) => INSET + (1 - (value - min) / span) * usable
const points = data.map((value, i) => `${x(i)},${y(value)}`).join(" ")
const lastX = x(data.length - 1)
const lastY = y(data[data.length - 1])
return root(
<svg
className="h-full w-full overflow-visible"
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
preserveAspectRatio="none"
aria-hidden="true"
>
{type === "area" && (
// A flat fill rather than a gradient: a gradient needs an id, ids must
// be unique per instance, and useId is not available to a component
// that renders on the server.
<polygon
points={`0,${height} ${points} ${VIEW_WIDTH},${height}`}
fill="currentColor"
fillOpacity={0.15}
/>
)}
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{/* A zero-length round-capped stroke paints a true circle. A <circle>
cannot: the viewBox scales horizontally only, so an r would render
as an ellipse that stretches with the card. */}
<line
x1={lastX}
y1={lastY}
x2={lastX}
y2={lastY}
stroke="currentColor"
strokeWidth={DOT_SIZE}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
)
}
// @use-when a dashboard figure: one number with its label, and optionally a
// delta and a sparkline.
function MetricCard({
title,
value,
goal,
delta,
unit,
badge,
action,
link,
trend,
children,
className,
...props
}: Omit<React.ComponentProps<typeof Card>, "title"> & {
/** Card header label. */
title: string
/** The headline number. */
value: React.ReactNode
/** The line under the value, e.g. "Goal: 262" or "vs. last month". */
goal?: React.ReactNode
/** Distance left to the goal. Renders "Goal reached!" once it hits zero. */
delta?: { value: number; label?: string }
/** Appended to `goal`, e.g. "lb". */
unit?: string
/** Sits inline beside the value. Pass an element: `badge={<Badge>+12%</Badge>}`. */
badge?: React.ReactNode
/** Header right slot, for a Kebab or an icon Button. */
action?: React.ReactNode
/** Header right slot as a text link. Ignored when `action` is set. */
link?: { label: string; onClick: () => void }
/** Recent history, drawn under the value. */
trend?: {
data: number[]
type?: React.ComponentProps<typeof Sparkline>["type"]
size?: React.ComponentProps<typeof Sparkline>["size"]
label?: string
}
/** Extra content in the value block, e.g. a Progress bar. */
children?: React.ReactNode
}) {
return (
<Card className={cn(className)} {...props}>
<DashboardCardHeader title={title} action={action} link={link} />
{/* THE CHART SITS ON THE CARD'S FLOOR, not wherever the text above it
happens to end. Metric cards live in a grid, a grid row stretches
every card to the tallest, and the text block is the part that varies:
a card with a `delta` line is one line taller than one without. Packed
from the top, that difference falls as dead space UNDER the chart, so
three cards in a row show three charts at three different heights and
the shortest text gets the biggest hole beneath it. Pinning the chart
down instead puts the slack above it, where it reads as breathing room,
and gives the row a common baseline to scan across.
`mt-auto` is a flex push absorbing free space, not a child setting a
gap to a sibling: the rung above still supplies the 16px minimum, and
on a card that is NOT stretched there is no free space, so this is
inert and the card keeps its natural height. */}
<CardContent className="flex flex-1 flex-col">
{/* Two rungs, not one. The goal line and anything passed as children
explain the value, so they bind to it at 8. The trend chart is a
peer artifact about the same metric, sitting under that whole
block at 16. One flat stack would put the chart at the same
distance as the goal line and read as a fourth line of text. */}
<Stack gap="cluster" className="flex-1">
<Stack gap="bind">
<MetricCardValue value={value} goal={goal} delta={delta} unit={unit}>
{badge}
</MetricCardValue>
{children}
</Stack>
{trend && (
<Sparkline
className="mt-auto"
data={trend.data}
type={trend.type}
size={trend.size}
label={trend.label ?? `${title} over the last ${trend.data.length} periods`}
/>
)}
</Stack>
</CardContent>
</Card>
)
}
// Two exports, one file, on purpose. `MetricCard` is the pattern; `Sparkline`
// is the chart it draws, still reachable on its own for a table row or a list
// item, where a card would be the wrong box entirely.
//
// The card is a COMPOSITION, not a new surface: `Card`, `DashboardCardHeader`,
// `MetricCardValue`, and `Stack` all ship separately, and what this adds is the
// rhythm, which is the part that was being retyped and quietly getting
// different at every call site. Reach past it to those parts whenever the
// layout differs; that is not a fork, it is what the parts are for.
//
// The chart paints with `currentColor` off a `text-accent` root, so one class
// on the element retargets every mark. Deliberately static: no enter
// animation, no hover. A metric card is read dozens of times a day, and a
// chart that replays its fill on every visit is noise, not delight.
export { MetricCard, Sparkline }