Metric Card

A dashboard card for one number: value, goal, delta badge, and its recent trend.

Installation

$
npx shadcn@latest add https://ui.mattdowney.com/r/metric-card.json

Usage

import { MetricCard, Sparkline } from "@/components/ui/metric-card"

Examples

Default

Title, value, goal, distance left, and the trend under it.

Subscribers
183
Goal: 26279 to go

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.

Monthly revenue
$128,400
vs. last month
Subscribers
183
Goal: 26279 to go
Churn rate
2.4%
vs. last month

Tiles

layout="tile" for a row of KPIs that are each one number, a status and one line: no header band, the label on top and the badge in the corner. A metric with a chart or more lines keeps the default.

Revenue
$128,400
vs. $114,200 last month
Active users
8,312
vs. 8,540 last month
Churn rate
2.4%
vs. 3.0% last month
Open tickets
42
12 waiting on a reply

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.

Monthly revenue
$128,400
86% of goal
x

No trend

Leave trend off and the card is just the number. Nothing collapses or shifts.

Open tickets
17
Across 4 projects

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.

bar
line
area

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.

sm
default
lg

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.

Organic search12,840
Direct6,190
Social1,908

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

PropTypeDefaultDescription
layout"default" | "tile""default"tile drops the header band: label on top, badge in the top-right corner, then the value and one line. For a row of one-number KPIs.
titlestringNoneThe card header label, or the small label above the value in tile.
valueReactNodeNoneThe headline number.
goalReactNodeNoneThe line under the value, e.g. "Goal: 262" or "vs. last month".
delta{ value: number; label?: string }NoneDistance left to the goal, rendered as "79 to go". Flips to "Goal reached!" at zero or below.
unitstringNoneAppended to goal, e.g. "lb".
badgeReactNodeNoneSits inline beside the value, or in the top-right corner in tile. Pass an element: badge={<Badge>+12%</Badge>}.
actionReactNodeNoneHeader right slot, for a Kebab or an icon Button. In tile it sits after the badge on the label row; use size="icon-sm" there.
link{ label: string; onClick: () => void }NoneHeader right slot as a text link. Ignored when action is set.
trend{ data: number[]; type?; size?; label? }NoneRecent history, drawn under the value by Sparkline. Omit it for a card with no chart.
childrenReactNodeNoneExtra content inside the value block, e.g. a Progress bar. Binds to the value at 8px.
dataSparklinenumber[]NoneThe 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.
labelSparklinestringNoneAccessible name. Generated from the data when omitted, in the form "52 points, 62 to 133, trending up".
classNameSparklinestringNoneMerged onto the root. Set the text color here to retarget every mark: className="text-destructive-500".
layout"default" | "tile" = "default"tile drops the header band: label on top, badge in the top-right corner, then the value and one line. For a row of one-number KPIs.
titlestringThe card header label, or the small label above the value in tile.
valueReactNodeThe headline number.
goalReactNodeThe 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.
unitstringAppended to goal, e.g. "lb".
badgeReactNodeSits inline beside the value, or in the top-right corner in tile. Pass an element: badge={<Badge>+12%</Badge>}.
actionReactNodeHeader right slot, for a Kebab or an icon Button. In tile it sits after the badge on the label row; use size="icon-sm" there.
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.
childrenReactNodeExtra content inside the value block, e.g. a Progress bar. Binds to the value at 8px.
dataSparklinenumber[]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.
labelSparklinestringAccessible name. Generated from the data when omitted, in the form "52 points, 62 to 133, trending up".
classNameSparklinestringMerged onto the root. Set the text color here to retarget every mark: className="text-destructive-500".