Metric value
A number formatted for its kind, with its change since the previous period and a button that shows its work.
Server-compatible: no hooks, no client boundary of its own — the popover behind provenance is the only client part, and it only mounts when there is provenance to show. The number and the meta row set their own type size rather than inheriting one, so handing a MetricValue to StatCard's value slot writes the delta at text-xs inside a text-2xl value. The chip itself is MetricDelta's — the same variant table, arrows and spoken direction word, so the registry holds one definition of what a delta chip looks like; only the tone rule differs, and that is the point: it comes from the Delta's favorable rather than the sign, so a fall in latency is good news and a fall in revenue is not. The direction is also read out in words for a screen reader, so nothing depends on colour alone. Give it label wherever more than one metric shares a page: four cards whose explain buttons are all called "Explain this number" read as four identical buttons when a screen reader lists them.
Install
npx shadcn@latest add @vibra/metric-valueNeeds the @vibra registry in your components.json — set it up once.
Examples
import { previousPeriod, traced, trailingPeriod } from "@/lib/metric"
import { MetricValue } from "@/components/ui/metric-value"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
// Pinned rather than read off a clock, so the label is the same every render.
const NOW = new Date("2026-09-04T15:40:00.000Z")
const RANGE = trailingPeriod(30, NOW)
const BEFORE = previousPeriod(RANGE)
// Four days of the month behind the headline number, so the popover has
// something real to show.
const DAYS = [
{ date: "2026-09-01", visitors: 1611, signups: 61 },
{ date: "2026-08-31", visitors: 1502, signups: 58 },
{ date: "2026-08-30", visitors: 1418, signups: 52 },
{ date: "2026-08-29", visitors: 1377, signups: 49 },
]
const { value: signups, provenance } = traced(
"daily signups",
DAYS,
"sum(signups)",
(rows) => rows.reduce((total, row) => total + row.signups, 0),
{ columns: ["date", "signups"], sample: 3 }
)
export default function MetricValueDemo() {
return (
<StatCardGroup className="w-full max-w-2xl" columns={2}>
<StatCard
label="Revenue"
value={
<MetricValue
metric={{ kind: "money", value: 128431, precision: 0 }}
previous={114226}
compareLabel={`vs ${BEFORE.label.toLowerCase()}`}
/>
}
/>
<StatCard
label="Signups"
value={
<MetricValue
metric={{ kind: "count", value: signups }}
previous={198}
compareLabel={`vs ${BEFORE.label.toLowerCase()}`}
label="Signups"
provenance={provenance}
/>
}
/>
<StatCard
label="Signup rate"
value={
<MetricValue
metric={{ kind: "percent", value: 0.0359, precision: 2 }}
previous={0.0371}
compareLabel={`vs ${BEFORE.label.toLowerCase()}`}
/>
}
/>
<StatCard
label="p95 latency"
value={
<MetricValue
metric={{ kind: "duration", value: 184, unit: "ms" }}
previous={212}
positiveIsGood={false}
compareLabel={`vs ${BEFORE.label.toLowerCase()}`}
/>
}
/>
</StatCardGroup>
)
}Sizes
The same number at sm, md, and lg.
import { MetricValue, type MetricValueSize } from "@/components/ui/metric-value"
const SIZES: MetricValueSize[] = ["sm", "md", "lg"]
export default function MetricValueSizes() {
return (
<div className="flex w-full max-w-md flex-col gap-6 panel p-4">
{SIZES.map((size) => (
<div key={size} className="flex flex-col gap-1.5">
<div className="font-mono text-xs text-muted-foreground">size="{size}"</div>
<MetricValue
size={size}
metric={{ kind: "money", value: 128431, precision: 0 }}
previous={114226}
compareLabel="vs previous 30 days"
/>
</div>
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| metric | Metric | — | The number and how to read it — from the metric lib. |
| previous | Metric | number | — | The same measure over the period before. A bare number is read in metric's own kind. A Metric whose kind, currency or unit differs from metric throws, since the two could not be compared honestly. Omit it and no delta chip renders. |
| positiveIsGood | boolean | true | False for metrics where down is the win — churn, latency, cost. |
| compareLabel | React.ReactNode | — | What the change is measured against, e.g. "vs previous 30 days". |
| label | string | — | What the number measures, e.g. "Signups". Not rendered — it names the explain button ("Explain Signups") so a page of metrics offers a page of distinct buttons. |
| provenance | Provenance | — | The rows behind the number. Present, it renders an explain button — named from label, or "Explain this number" without one — that opens ExplainNumber. |
| size | "sm" | "md" | "lg" | "md" | Sets the number at text-lg, text-2xl, or text-3xl, and scales the meta row. |
| format | { locale?: string; compact?: boolean } | — | Passed through to formatMetric — a locale, and whether to abbreviate. |
Dependencies
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import {
compareMetric,
formatDelta,
formatMetric,
type DeltaDirection,
type FormatMetricOptions,
type Metric,
type Provenance,
} from "@/lib/metric"
import { ExplainNumber } from "@/components/ui/explain-number"
import {
TREND_ICONS,
TREND_LABELS,
metricDeltaVariants,
type MetricDeltaTrend,
} from "@/components/ui/metric-delta"
const metricValueVariants = cva("flex flex-col", {
variants: {
size: { sm: "gap-0.5", md: "gap-1", lg: "gap-1.5" },
},
defaultVariants: { size: "md" },
})
// The numeral register. Both the number and the meta row set their own size and
// their own tracking rather than inheriting one, so a MetricValue handed to
// StatCard's `value` slot — which is itself 30px on the numeral track — still
// writes its delta at 12px on normal tracking. Whatever tracking a preset gives
// the digits belongs to the digits and to nothing under them.
const metricNumberVariants = cva("type-numeral", {
variants: {
size: { sm: "text-xl", md: "text-3xl", lg: "text-4xl" },
},
defaultVariants: { size: "md" },
})
const metricMetaVariants = cva(
"flex flex-wrap items-center gap-x-2 gap-y-1 font-sans font-normal tracking-normal",
{
variants: {
size: { sm: "text-xs", md: "text-xs", lg: "text-sm" },
},
defaultVariants: { size: "md" },
}
)
// The chip is MetricDelta's pill — same variant table, same arrows, same word
// before the number — because two independent copies of one chip in the
// registry drift apart quietly. Only the tone rule differs, and that is the
// point of this component: it comes from the Delta's `favorable`, never from
// the sign, so a fall in latency is good news and a fall in revenue is not.
const DELTA_TREND: Record<DeltaDirection, MetricDeltaTrend> = {
up: "up",
down: "down",
flat: "neutral",
}
// MetricDelta has two densities to this component's three: sm and md both sit in
// a text-xs meta row, lg in a text-sm one.
const DELTA_SIZE = { sm: "sm", md: "sm", lg: "default" } as const
export type MetricValueSize = NonNullable<VariantProps<typeof metricValueVariants>["size"]>
export type MetricValueProps = Omit<React.ComponentProps<"div">, "children"> & {
metric: Metric
/** The same measure over the period before. A bare number is read in `metric`'s own kind. */
previous?: Metric | number
/** False for metrics where down is the win — churn, latency, cost. */
positiveIsGood?: boolean
/** What the change is measured against, e.g. "vs previous 30 days". */
compareLabel?: React.ReactNode
/**
* What the number measures, e.g. "Signups". Not rendered — the card or row
* around it already writes the label — but it names the explain button
* ("Explain Signups"), so a page of them offers a page of distinct buttons
* rather than four called "Explain this number".
*/
label?: string
/** The rows behind the number; renders the "Explain this number" button. */
provenance?: Provenance
size?: MetricValueSize
/** Locale and compactness for the number itself. */
format?: FormatMetricOptions
}
/**
* A number that explains itself: formatted for its kind, with the change since
* the previous period, what that change is measured against, and — when it
* knows where it came from — a button that opens the rows behind it.
*/
function MetricValue({
className,
metric,
previous,
positiveIsGood = true,
compareLabel,
label,
provenance,
size = "md",
format,
...props
}: MetricValueProps) {
const formatted = formatMetric(metric, format)
const before =
previous === undefined
? undefined
: typeof previous === "number"
? { ...metric, value: previous }
: previous
const delta = before ? compareMetric(metric, before, { positiveIsGood }) : undefined
const tone = !delta || delta.favorable === null ? "neutral" : delta.favorable ? "good" : "bad"
const trend = delta ? DELTA_TREND[delta.direction] : null
const TrendIcon = trend ? TREND_ICONS[trend] : null
const hasMeta = delta !== undefined || Boolean(compareLabel) || provenance !== undefined
return (
<div
data-slot="metric-value"
data-size={size}
className={cn(metricValueVariants({ size }), className)}
{...props}
>
<div data-slot="metric-value-number" className={metricNumberVariants({ size })}>
{formatted}
</div>
{hasMeta ? (
<div data-slot="metric-value-meta" className={metricMetaVariants({ size })}>
{delta && trend && TrendIcon ? (
<span
data-slot="metric-value-delta"
data-direction={delta.direction}
data-tone={tone}
// Through cn like MetricDelta's own root, so the variant table's
// base icon size and its density override collapse the same way.
className={cn(
metricDeltaVariants({ tone, variant: "pill", size: DELTA_SIZE[size] })
)}
>
<TrendIcon aria-hidden="true" />
<span className="sr-only">{`${TREND_LABELS[trend]} `}</span>
{formatDelta(delta, metric.kind, {
locale: format?.locale,
currency: metric.currency,
unit: metric.unit,
precision: metric.precision,
})}
</span>
) : null}
{compareLabel ? (
<span data-slot="metric-value-compare" className="text-muted-foreground">
{compareLabel}
</span>
) : null}
{provenance ? (
<ExplainNumber
provenance={provenance}
value={formatted}
triggerLabel={label ? `Explain ${label}` : undefined}
className="-my-1"
/>
) : null}
</div>
) : null}
</div>
)
}
export { MetricValue, metricValueVariants }