A segmented band of KPI cards on one surface with hairline seams, improvement-aware deltas, inline sparklines, staggered first-view reveal, and an optional single-select mode with a gliding indicator.
npx shadcn@latest add @paragon/metric-card-groupAlso installs: sparkline, tooltip
"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Sparkline } from "@/registry/paragon/ui/sparkline";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface MetricCardItem {
id: string;
label: string;
/** Preformatted display value, e.g. "$128.4k". */
value: string;
/** Signed percent vs the prior period, e.g. -4.2. */
delta?: number;
/** Is a rising delta good? Latency rising is bad: pass "down". */
positiveIs?: "up" | "down";
/** Definition shown in a tooltip on the label. */
hint?: string;
/** Sparkline series, oldest first. */
data?: number[];
}
export interface MetricCardGroupProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
items: MetricCardItem[];
/** Columns at full container width; narrow containers fall back to 2. */
columns?: 2 | 3 | 4;
/** Cards become a single-select group (for driving a chart below). */
selectable?: boolean;
defaultSelectedId?: string;
onSelect?: (item: MetricCardItem) => void;
/** Renders skeleton cells in place of data. */
loading?: boolean;
/** Renders cells in place with no reveal or indicator motion. */
static?: boolean;
}
const COLUMN_CLASS: Record<2 | 3 | 4, string> = {
2: "grid-cols-2",
3: "grid-cols-2 @lg:grid-cols-3",
4: "grid-cols-2 @2xl:grid-cols-4",
};
/**
* A segmented band of metric cards on one surface — hairline seams, a
* label with an optional definition tooltip, tabular value, improvement-
* aware delta, and an inline sparkline tinted by whether the move is
* good. Cells blur-rise with a stagger on first view. With `selectable`,
* the band becomes a single-select group (roving tabindex, arrow keys)
* and the active indicator bar glides between cells — wire `onSelect` to
* swap the chart underneath.
*/
export function MetricCardGroup({
items,
columns = 4,
selectable = false,
defaultSelectedId,
onSelect,
loading = false,
static: isStatic = false,
className,
...props
}: MetricCardGroupProps) {
const uid = React.useId();
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const reducedMotion = useReducedMotion() ?? false;
const noMotion = reducedMotion || isStatic;
const shown = isStatic || reducedMotion || inView;
const [selected, setSelected] = React.useState<string | null>(
defaultSelectedId ?? (selectable ? (items[0]?.id ?? null) : null),
);
const [focused, setFocused] = React.useState<string | null>(null);
const cellRefs = React.useRef(new Map<string, HTMLButtonElement>());
const focusId = (id: string | undefined) => {
if (!id) return;
setFocused(id);
cellRefs.current.get(id)?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
const ids = items.map((i) => i.id);
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusId(ids[(index + 1) % ids.length]);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusId(ids[(index - 1 + ids.length) % ids.length]);
break;
case "Home":
event.preventDefault();
focusId(ids[0]);
break;
case "End":
event.preventDefault();
focusId(ids[ids.length - 1]);
break;
}
};
const focusTarget = focused ?? selected ?? items[0]?.id;
if (!loading && items.length === 0) {
return (
<div
data-slot="metric-card-group"
className={cn(
"flex items-center justify-center rounded-xl border border-dashed px-4 py-10 text-xs text-muted-foreground",
className,
)}
{...props}
>
No metrics for this range.
</div>
);
}
return (
<TooltipProvider>
<div className={cn("@container w-full", className)}>
<div
ref={ref}
data-slot="metric-card-group"
role={selectable ? "radiogroup" : undefined}
aria-label={selectable ? "Select a metric" : undefined}
className={cn(
"grid w-full overflow-hidden rounded-xl bg-card shadow-border",
COLUMN_CLASS[columns],
)}
{...props}
>
{loading
? Array.from({ length: columns }, (_, i) => (
<div
key={i}
aria-hidden
className="flex flex-col gap-3 p-4 shadow-[-1px_-1px_0_0_var(--color-border)]"
>
<span
className="h-3 w-20 animate-pulse rounded bg-muted motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
<span
className="h-6 w-24 animate-pulse rounded bg-muted motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
<span
className="h-3 w-14 animate-pulse rounded bg-muted motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
</div>
))
: items.map((item, index) => {
const positiveIs = item.positiveIs ?? "up";
const improved =
item.delta === undefined || item.delta === 0
? null
: positiveIs === "up"
? item.delta > 0
: item.delta < 0;
const isSelected = selectable && selected === item.id;
const cellClassName = cn(
"relative flex min-w-0 flex-col items-stretch gap-1 p-4 text-left",
"shadow-[-1px_-1px_0_0_var(--color-border)]",
"transition-[background-color,opacity,translate] duration-(--duration-base) ease-(--ease-out)",
selectable &&
"cursor-pointer focus-visible:z-10 focus-visible:-outline-offset-2",
selectable && !isSelected && "hover:bg-muted/30",
isSelected && "bg-muted/40",
// First-view reveal — opacity + rise, staggered.
noMotion
? shown
? "opacity-100"
: "opacity-0"
: shown
? "translate-y-0 opacity-100"
: "translate-y-1.5 opacity-0",
);
const cellStyle =
shown && !noMotion
? { transitionDelay: `${Math.min(index, 8) * 60}ms` }
: undefined;
const content = (
<>
{isSelected &&
(noMotion ? (
<span
aria-hidden
className="absolute inset-x-0 top-0 h-0.5 bg-primary"
/>
) : (
<motion.span
aria-hidden
layoutId={`${uid}-indicator`}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="absolute inset-x-0 top-0 h-0.5 bg-primary"
/>
))}
<span className="min-w-0 truncate text-xs text-muted-foreground">
{item.hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default underline decoration-border decoration-dotted underline-offset-4">
{item.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">{item.hint}</TooltipContent>
</Tooltip>
) : (
item.label
)}
</span>
<span className="flex items-end justify-between gap-3">
<span
className="min-w-0 truncate text-[22px] leading-7 font-semibold tracking-tight tabular-nums"
title={item.value}
>
{item.value}
</span>
{item.data && item.data.length > 1 && (
<Sparkline
data={item.data}
width={72}
height={26}
strokeWidth={1.25}
trend={
improved === null
? "neutral"
: improved
? "up"
: "down"
}
static={isStatic}
className="mb-0.5"
aria-label={`${item.label} trend, latest ${item.value}`}
/>
)}
</span>
{item.delta !== undefined && (
<span
className={cn(
"inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums",
improved === null
? "text-muted-foreground"
: improved
? "text-success"
: "text-destructive",
)}
>
{item.delta === 0 ? null : item.delta > 0 ? (
<ArrowUpRight aria-hidden className="size-3.5" />
) : (
<ArrowDownRight aria-hidden className="size-3.5" />
)}
{item.delta > 0 ? "+" : item.delta < 0 ? "−" : ""}
{Math.abs(item.delta).toFixed(1)}%
<span className="ml-0.5 font-normal text-muted-foreground">
vs last period
</span>
</span>
)}
</>
);
return selectable ? (
<button
key={item.id}
type="button"
role="radio"
aria-checked={isSelected}
tabIndex={focusTarget === item.id ? 0 : -1}
ref={(el) => {
if (el) cellRefs.current.set(item.id, el);
else cellRefs.current.delete(item.id);
}}
onClick={() => {
setSelected(item.id);
setFocused(item.id);
onSelect?.(item);
}}
onKeyDown={(event) => handleKeyDown(event, index)}
onFocus={() => setFocused(item.id)}
className={cellClassName}
style={cellStyle}
>
{content}
</button>
) : (
<div key={item.id} className={cellClassName} style={cellStyle}>
{content}
</div>
);
})}
</div>
</div>
</TooltipProvider>
);
}