Stat card group
A responsive grid of stat cards, optionally collapsed into one hairline-divided panel.
divided renders StatCard children with variant="flush" so the group owns the single frame; a child that sets its own variant keeps it. A card nested inside a wrapper — a Link around a clickable tile, a Fragment, your own re-export — is still rendered flush, by CSS rather than by the injection, so it keeps data-variant="card" while losing the chrome. Those selectors are descendant selectors, so a StatCard rendered inside another card's footer also goes flush; give it its own frame if you need one. The fill is reset at the weight of a single class, so a tile's state outweighs it: a divided, selectable row keeps its chosen tile on the brand tint, and a hovered tile its wash. The grid starts at one column for 2 and 3 and at two for 4 and 5 — a KPI row on a phone is two up — and widens at the steps the columns prop lists.
Install
npx shadcn@latest add @vibra/stat-card-groupNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
export default function StatCardGroupDemo() {
return (
<StatCardGroup className="w-full" columns={3}>
<StatCard
label="Revenue"
value={formatCurrency(128431, "USD", { maximumFractionDigits: 0 })}
delta={0.124}
description="vs last month"
/>
<StatCard
label="Orders"
value={formatNumber(3912, { maximumFractionDigits: 0 })}
delta={0.021}
description="vs last month"
/>
<StatCard
label="Refund rate"
value={formatPercent(0.018)}
delta={0.004}
positiveIsGood={false}
description="vs last month"
/>
</StatCardGroup>
)
}Divided
Four cards in one panel, separated by hairlines instead of gaps.
import { formatCurrency, formatDuration, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
export default function StatCardGroupDivided() {
return (
<StatCardGroup className="w-full" columns={4} divided>
<StatCard
label="Revenue"
value={formatCurrency(128431, "USD", { maximumFractionDigits: 0 })}
delta={0.124}
/>
<StatCard
label="Orders"
value={formatNumber(3912, { maximumFractionDigits: 0 })}
delta={0.021}
/>
<StatCard label="Conversion rate" value={formatPercent(0.034)} delta={-0.006} />
<StatCard
label="p95 latency"
value={formatDuration(412)}
delta={-0.081}
positiveIsGood={false}
/>
</StatCardGroup>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | 2 | 3 | 4 | 5 | 3 | Columns at the widest breakpoint, and the steps down to a phone, narrowest first: 2 → 1, sm:2; 3 → 1, sm:2, lg:3; 4 → 2, lg:4; 5 → 2, lg:3, xl:5. 4 and 5 never show one column, and 4 never shows three. |
| divided | boolean | false | Draws one bordered panel with hairlines between cells instead of separate cards. |
| selectable | boolean | false | Makes the tiles choosable, controlled by value and onValueChange; the selected one wears the brand tint. With controls the row is a tablist — arrow keys and Home/End walk it, one tab stop — and without it a group of toggle buttons, each its own stop. |
| controls | string | — | The id of the panel the tiles drive, such as the chart they re-bind. Only a row that names one is a tablist: leave it off when the panel is not on the page, and no tile points at an id that is not there. An empty or blank string names none, for the row and its tiles alike. |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { namesPanel, StatCard, type StatCardProps } from "@/components/ui/stat-card"
const statCardGroupVariants = cva("@container/stat-cards grid", {
variants: {
columns: {
// The exact steps: 2 is one column, two from sm. 3 holds its count from
// lg up, then drops to two at sm and one below. 4 and 5 never show one
// column — a KPI row on a phone is two up, which is what keeps it inside
// a screen height — so 4 goes 2 → 4 at lg and 5 goes 2 → 3 (lg) → 5 (xl).
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-2 lg:grid-cols-4",
5: "grid-cols-2 lg:grid-cols-3 xl:grid-cols-5",
},
divided: {
// One frame around the whole row, drawn two ways on purpose.
//
// Hairlines: every cell draws its own top and left rule and is pulled a
// pixel up and left, so the outermost lines fall outside the frame's
// padding box and get clipped. That holds at any column count and on
// every wrapped row, unlike divide-x/divide-y, which leaves a line on the
// last cell of each row — i.e. on the frame's own edge. The line is
// `--rule`, not `--border`: inside one sheet it is a divider, and a
// divider is the heavier of the two weights.
//
// Flushness: cloneElement below strips the chrome off direct StatCard
// children, but a card nested in a wrapper (a <Link> around a clickable
// tile, a Fragment, a user's own re-export) never matches that identity
// check. The descendant selectors reach those too — specificity (0,2,0)
// beats the card primitive's own single-class rules deterministically —
// so a wrapped card still renders flush even though it keeps
// data-variant="card".
//
// The fill is the exception, reset inside :where() at the weight of one
// class: it still beats the card's own plane (bg-surface, a tone), which
// comes earlier in the sheet, but a tile's state outweighs it — the
// chosen tile keeps its brand tint and a hovered one its wash. At
// (0,2,0) it tied with data-[selected=true]:bg-brand-muted and won on
// source order, so a divided, selectable row hid which tile was chosen.
true: "overflow-hidden panel [&>*]:-mt-px [&>*]:-ms-px [&>*]:border-t [&>*]:border-s [&>*]:border-rule [&_[data-slot=stat-card]]:rounded-none [&_:where([data-slot=stat-card])]:bg-transparent [&_[data-slot=stat-card]]:ring-0 [&_[data-slot=stat-card]]:shadow-none",
false: "gap-[var(--density-gap,1rem)]",
},
},
defaultVariants: { columns: 3, divided: false },
})
// A narrow group reads at the small register whatever the viewport is doing:
// four tiles in a 1,440px page and four tiles in a 380px grid column are the
// same layout problem, and a media query only knows about the first. This is
// what `size="sm"` would set on each tile, applied from the container instead —
// the smaller numeral, the tighter rhythm, the compact padding, no sparkline —
// plus one thing a size cannot do: the delta's referent goes screen-reader-only
// rather than wrapping onto a second line, which is what keeps a four-up KPI
// row inside 220px on a phone instead of 330.
const COMPACT_BELOW_640 =
[
"@max-[40rem]/stat-cards:[&_[data-slot=stat-card]]:gap-1.5",
"@max-[40rem]/stat-cards:[&_[data-slot=stat-card]]:[--card-spacing:--spacing(3)]",
"@max-[40rem]/stat-cards:[&_[data-slot=stat-card-value]]:text-xl",
// A MetricValue handed to the value slot sets its own size, so the tile's
// is not enough: the number itself has to come down with it.
"@max-[40rem]/stat-cards:[&_[data-slot=metric-value-number]]:text-xl",
"@max-[40rem]/stat-cards:[&_[data-slot=stat-card-footer]]:hidden",
"@max-[40rem]/stat-cards:[&_[data-slot=stat-card-description]]:sr-only",
"@max-[40rem]/stat-cards:[&_[data-slot=metric-value-compare]]:sr-only",
].join(" ")
export type StatCardGroupProps = React.ComponentProps<"div"> & {
columns?: NonNullable<VariantProps<typeof statCardGroupVariants>["columns"]>
/** Collapses the cards into one bordered panel separated by hairlines. */
divided?: boolean
/**
* Makes the tiles choosable; the selected one wears the brand tint.
* Controlled — the state belongs to whatever the tiles drive, usually the
* chart beneath them — so a selectable group is rendered from a client
* island. With `controls` the row is a tablist: each tile a tab of that
* panel, arrow keys and Home/End walk it, and it is one tab stop. Without,
* the tiles are toggle buttons in a group, each its own stop.
*/
selectable?: boolean
/**
* The id of the panel the tiles drive — the chart they re-bind. Only a row
* that names one is a tablist: a tab of nothing is not a tab, so leave it
* off when the panel is not on the page. Leave it off, too, when a tile holds
* a control of its own (an Explain button, a link): a tablist may hold only
* its tabs, so such a row is a group of toggle buttons.
*/
controls?: string
/** The selected tile's `id`, or its index as a string when it has none. */
value?: string
onValueChange?: (value: string) => void
}
/** The key a tile reports: its own id when it has one, its position otherwise. */
function keyOf(child: React.ReactNode, index: number) {
return (React.isValidElement<{ id?: string }>(child) && child.props.id) || String(index)
}
function StatCardGroup({
className,
children,
columns = 3,
divided = false,
selectable = false,
controls,
value,
onValueChange,
...props
}: StatCardGroupProps) {
const keys = React.Children.toArray(children).map(keyOf)
const tabs = selectable && namesPanel(controls)
// A divided group owns the frame, so its cards render flush — unless a card
// asked for a variant itself, which stays an escape hatch. This only reaches
// direct StatCard children; the CSS above covers wrapped ones.
const cells = React.Children.map(children, (child, index) => {
if (!React.isValidElement<StatCardProps>(child) || child.type !== StatCard) return child
const key = keys[index]
return React.cloneElement(child, {
...(divided ? { variant: child.props.variant ?? "flush" } : null),
...(selectable
? {
selectable: true,
selected: value === key,
onSelect: () => onValueChange?.(key),
}
: null),
...(tabs
? {
controls,
// With a value that names no tile — a fresh group, a stale id —
// every tile would be -1 and the group would fall out of the tab
// order entirely; the first one holds the stop instead.
tabIndex: value === key || (!keys.includes(value ?? "") && index === 0) ? 0 : -1,
}
: null),
})
})
// Focus moves without a ref or a hook, so the module stays shared: the tabs
// are in DOM order, which is children order, so the index of the focused one
// is the index of its key.
const onKeyDown = tabs
? (event: React.KeyboardEvent<HTMLDivElement>) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0
const tabs = [...event.currentTarget.querySelectorAll<HTMLElement>("[role=tab]")]
if (tabs.length === 0) return
const from = tabs.indexOf(document.activeElement as HTMLElement)
let next = -1
if (step !== 0 && from !== -1) next = (from + step + tabs.length) % tabs.length
else if (event.key === "Home") next = 0
else if (event.key === "End") next = tabs.length - 1
if (next === -1) return
event.preventDefault()
tabs[next].focus()
onValueChange?.(keys[next])
}
: undefined
return (
<div
data-slot="stat-card-group"
data-columns={columns}
data-divided={divided || undefined}
data-selectable={selectable || undefined}
role={tabs ? "tablist" : selectable ? "group" : undefined}
onKeyDown={onKeyDown}
className={cn(
statCardGroupVariants({ columns, divided }),
COMPACT_BELOW_640,
className
)}
{...props}
>
{cells}
</div>
)
}
export { StatCardGroup, statCardGroupVariants }