Metric delta
A signed change with a trend arrow, colored by whether the movement is good or bad.
Tone is derived, never passed: up plus positiveIsGood (or down plus positiveIsGood={false}) is good, the reverse is bad, and a zero change is neutral. The direction is also announced as text ("up +12.0%"), so it never reads by color alone.
Install
$
npx shadcn@latest add @vibra/metric-deltaNeeds the @vibra registry in your components.json — set it up once.
Examples
import { cn } from "@/lib/utils"
import { MetricDelta, type MetricDeltaProps } from "@/components/ui/metric-delta"
type Row = Pick<MetricDeltaProps, "value" | "format" | "positiveIsGood"> & { metric: string }
// `positiveIsGood: false` is what makes a rise in refunds or latency read as bad
// and a fall read as good — the same number, the opposite verdict.
const ROWS: Row[] = [
{ metric: "Revenue", value: 0.124 },
{ metric: "Orders", value: -3, format: "number" },
{ metric: "Refund rate", value: 0.004, positiveIsGood: false },
{ metric: "p95 latency", value: -0.081, positiveIsGood: false },
{ metric: "Sessions", value: 0 },
]
const ROW = "grid grid-cols-[1fr_4.5rem_5.5rem_5.5rem] items-center gap-x-4 px-3 py-2"
export default function MetricDeltaDemo() {
return (
<div className="w-full max-w-sm overflow-hidden panel">
<div className={cn(ROW, "border-b text-xs text-muted-foreground")}>
<span>Week over week</span>
<span className="justify-self-end">Text</span>
<span className="justify-self-end">Badge</span>
<span className="justify-self-end">Pill</span>
</div>
<div className="divide-y">
{ROWS.map((row) => (
<div key={row.metric} className={ROW}>
<span className="text-sm">{row.metric}</span>
<MetricDelta
value={row.value}
format={row.format}
positiveIsGood={row.positiveIsGood}
className="justify-self-end"
/>
<MetricDelta
value={row.value}
format={row.format}
positiveIsGood={row.positiveIsGood}
variant="badge"
className="justify-self-end"
/>
<MetricDelta
value={row.value}
format={row.format}
positiveIsGood={row.positiveIsGood}
variant="pill"
size="sm"
className="justify-self-end"
/>
</div>
))}
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The change itself: a ratio for "percent" (0.12 → "+12.0%"), a raw amount otherwise. |
| format | "percent" | "number" | "compact" | "percent" | Passed straight to formatDelta, which uses U+2212 for negatives. |
| trend | "up" | "down" | "neutral" | sign of value | Set it to describe a movement the number alone does not, e.g. a flat-but-late metric. |
| positiveIsGood | boolean | true | False for metrics where down is the win — churn, latency, cost. |
| showIcon | boolean | true | Shows the trend arrow. |
| variant | "text" | "badge" | "pill" | "text" | badge sets the chip on the tone's muted background; pill is the capsule a card strip carries — one line tall, rounded full, ringed in its own tone — which StatCard and MetricValue use. |
| size | "sm" | "default" | "default" | sm drops the text to text-xs and the arrow to size-3. |
Dependencies
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDelta } from "@/lib/format"
const metricDeltaVariants = cva(
"inline-flex w-fit items-center gap-1 font-medium whitespace-nowrap tabular-nums [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
// Tone is derived from `trend` and `positiveIsGood`, never passed in: a
// drop in churn is good, a drop in revenue is not.
tone: {
good: "text-success",
bad: "text-danger",
neutral: "text-muted-foreground",
},
variant: {
text: "",
badge: "rounded-md px-1.5 py-0.5",
// The annotation on a card strip: a tinted capsule with a hairline of
// its own tone, one line tall.
pill: "h-5 rounded-full px-2 ring-1 ring-inset",
},
size: {
default: "text-sm",
sm: "text-xs [&_svg:not([class*='size-'])]:size-3",
},
},
compoundVariants: [
{ variant: "badge", tone: "good", className: "bg-success-muted" },
{ variant: "badge", tone: "bad", className: "bg-danger-muted" },
{ variant: "badge", tone: "neutral", className: "bg-muted" },
{ variant: "pill", tone: "good", className: "bg-success-muted ring-success/20" },
{ variant: "pill", tone: "bad", className: "bg-danger-muted ring-danger/20" },
{ variant: "pill", tone: "neutral", className: "bg-muted ring-border" },
],
defaultVariants: { tone: "neutral", variant: "text", size: "default" },
}
)
/** The arrow each trend draws. Exported so anything else rendering a delta chip draws the same one. */
export const TREND_ICONS = { up: ArrowUpIcon, down: ArrowDownIcon, neutral: MinusIcon } as const
// Read out before the number so the direction never depends on color alone —
// "up +12.0%", "down −3", "no change 0%".
/** The word a screen reader hears before the number. Exported alongside TREND_ICONS. */
export const TREND_LABELS = { up: "up", down: "down", neutral: "no change" } as const
export type MetricDeltaTrend = keyof typeof TREND_ICONS
export type MetricDeltaProps = React.ComponentProps<"span"> & {
/** The change itself: a ratio for "percent" (0.12 → "+12.0%"), a raw amount otherwise. */
value: number
format?: "percent" | "number" | "compact"
/** Defaults to the sign of `value`. Set it to describe a change the number alone doesn't. */
trend?: "up" | "down" | "neutral"
/** False for metrics where down is the win — churn, latency, cost. */
positiveIsGood?: boolean
showIcon?: boolean
variant?: NonNullable<VariantProps<typeof metricDeltaVariants>["variant"]>
size?: NonNullable<VariantProps<typeof metricDeltaVariants>["size"]>
}
function MetricDelta({
className,
value,
format = "percent",
trend,
positiveIsGood = true,
showIcon = true,
variant = "text",
size = "default",
...props
}: MetricDeltaProps) {
const resolvedTrend = trend ?? (value > 0 ? "up" : value < 0 ? "down" : "neutral")
const isGood = resolvedTrend === "up" ? positiveIsGood : !positiveIsGood
const tone = resolvedTrend === "neutral" ? "neutral" : isGood ? "good" : "bad"
const TrendIcon = TREND_ICONS[resolvedTrend]
return (
<span
data-slot="metric-delta"
data-trend={resolvedTrend}
data-tone={tone}
className={cn(metricDeltaVariants({ tone, variant, size }), className)}
{...props}
>
{showIcon ? <TrendIcon aria-hidden="true" /> : null}
<span className="sr-only">{`${TREND_LABELS[resolvedTrend]} `}</span>
{formatDelta(value, { style: format })}
</span>
)
}
export { MetricDelta, metricDeltaVariants }