Data displayui
Metric card
KPI value with change versus a previous period and a sparkline.
Installation
npx shadcn@latest add @particular/metric-cardnpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Source
import * as React from "react"
import { ArrowDownRightIcon, ArrowUpRightIcon, MinusIcon } from "lucide-react"
import { cn } from "cn"
import { Card } from "@/components/ui/card"
/** SVG path for a sparkline that fills `width`×`height` with 1px vertical padding. */
function sparklinePath(values: number[], width: number, height: number): string {
if (values.length < 2) return ""
const min = Math.min(...values)
const max = Math.max(...values)
const span = max - min || 1
const step = width / (values.length - 1)
return values
.map((value, i) => {
const x = i * step
const y = 1 + (height - 2) * (1 - (value - min) / span)
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`
})
.join(" ")
}
function Sparkline({ values, className }: { values: number[]; className?: string }) {
const width = 120
const height = 32
const line = sparklinePath(values, width, height)
if (!line) return null
return (
<svg
aria-hidden
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={cn("h-8 w-full overflow-visible", className)}
>
<path d={`${line} L${width},${height} L0,${height} Z`} className="fill-current opacity-10" />
<path
d={line}
className="fill-none stroke-current"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
</svg>
)
}
const percentFormat = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
})
function MetricCard({
label,
value,
delta,
deltaLabel,
invertDelta = false,
trend,
icon,
className,
...props
}: Omit<React.ComponentProps<typeof Card>, "children"> & {
label: React.ReactNode
value: React.ReactNode
/** Relative change, e.g. `0.124` for +12.4%. */
delta?: number
deltaLabel?: React.ReactNode
/** Set when lower is better (error rate, latency): a decrease renders as positive. */
invertDelta?: boolean
trend?: number[]
icon?: React.ReactNode
}) {
const direction = delta === undefined || delta === 0 ? "flat" : delta > 0 ? "up" : "down"
const good = direction === "flat" ? null : (direction === "up") !== invertDelta
const DeltaIcon = direction === "up" ? ArrowUpRightIcon : direction === "down" ? ArrowDownRightIcon : MinusIcon
return (
<Card data-slot="metric-card" className={cn("gap-3 px-4", className)} {...props}>
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{label}</span>
{icon && <span className="[&_svg]:size-4">{icon}</span>}
</div>
<div className="font-heading text-2xl font-semibold tracking-tight tabular-nums">{value}</div>
{delta !== undefined && (
<div className="flex items-center gap-1.5 text-xs">
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium tabular-nums",
good === null && "text-muted-foreground",
good === true && "text-success",
good === false && "text-destructive"
)}
>
<DeltaIcon className="size-3.5" aria-hidden />
{percentFormat.format(delta)}
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
)}
{trend && (
<Sparkline
values={trend}
className={cn(good === false ? "text-destructive" : good === true ? "text-success" : "text-brand")}
/>
)}
</Card>
)
}
export { MetricCard, Sparkline, sparklinePath }