Stat Cards
KPI stat card row with delta chips.
Plain
A KPI row with delta chips.
Customers
14,592
+5.3%Unit sold
385
-2.1%Orders
1,394
0.00%Support tickets
708
+12.8%function StatCardsDemo() {
return <StatCards stats={PLAIN_STATS} />;
}function StatCardsDemo() {
return <StatCards stats={PLAIN_STATS} />;
}Footer
The tinted-tile variant with comparison captions.
Total revenue
$152,313.92
From last month
16%Total orders
25,162
From last month
20%function StatCardsFooter() {
return <StatCards variant="footer" stats={FOOTER_STATS} count={2} columns={2} />;
}function StatCardsFooter() {
return <StatCards variant="footer" stats={FOOTER_STATS} count={2} columns={2} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/stat-cards.jsonnpx shadcn@latest add https://boardcn.dev/r/stat-cards.jsonnpm packages
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import type { ComponentType } from "react";
import {
RiArrowDownCircleFill,
RiArrowUpCircleFill,
RiIndeterminateCircleFill,
RiInformationFill,
} from "@remixicon/react";
import { Focusable } from "react-aria-components";
import { Chip } from "@/components/base/badges/chip";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type StatCardsVariant = "plain" | "footer";
/** Tint of the footer variant's gradient icon tile. */
export type StatTone = "blue" | "orange" | "purple" | "pink" | "sky" | "emerald";
export type Stat = {
icon: IconComponent;
label: string;
value: string;
delta: string;
deltaColor: "lime" | "rose" | "neutral";
/** Footer variant: icon tile tint (defaults to blue). */
tone?: StatTone;
/** Footer variant: comparison caption in the band ("From last month"). */
caption?: string;
/** Footer variant: shows an info glyph with this text on hover. */
hint?: string;
};
/** Gradient stops for the footer variant's icon tile, keyed by tone. */
const TILE_TONES: Record<StatTone, string> = {
blue: "from-blue-500 to-blue-600",
orange: "from-orange-400 to-orange-500",
purple: "from-purple-500 to-purple-600",
pink: "from-pink-500 to-pink-600",
sky: "from-sky-400 to-sky-500",
emerald: "from-emerald-500 to-emerald-600",
};
const DELTA_STYLES: Record<Stat["deltaColor"], { icon: IconComponent; className: string }> = {
lime: { icon: RiArrowUpCircleFill, className: "text-status-lime-text" },
rose: { icon: RiArrowDownCircleFill, className: "text-status-rose-text" },
neutral: { icon: RiIndeterminateCircleFill, className: "text-text-tertiary" },
};
/** White pill with a direction glyph — the footer band's delta readout. */
function DeltaPill({ delta, deltaColor }: Pick<Stat, "delta" | "deltaColor">) {
const { icon: Icon, className } = DELTA_STYLES[deltaColor];
return (
<span className="flex shrink-0 items-center gap-1 rounded-full border border-border-button-default bg-background-primary-default py-0.5 pr-2 pl-1 shadow-xs">
<Icon className={cx("size-4 shrink-0", className)} aria-hidden />
<span className={cx("text-body-medium whitespace-nowrap tabular-nums", className)}>
{delta}
</span>
</span>
);
}
/** Bare info glyph with a tooltip — the footer header's trailing control. */
function StatHint({ label, hint }: { label: string; hint: string }) {
return (
<TooltipTrigger delay={200}>
<Focusable>
<button
type="button"
aria-label={`About ${label}`}
className="flex shrink-0 cursor-pointer items-center justify-center rounded-full text-foreground-icon-secondary outline-none transition-colors duration-150 ease hover:text-foreground-icon-primary focus-visible:ring-2 focus-visible:ring-border-focus-ring"
>
<RiInformationFill className="size-5" aria-hidden />
</button>
</Focusable>
<Tooltip size="md">{hint}</Tooltip>
</TooltipTrigger>
);
}
function PlainStatCard({ stat }: { stat: Stat }) {
return (
<section className="flex h-[132px] min-w-0 flex-col items-start justify-between rounded-2xl bg-background-secondary-default p-4">
<span className="flex items-center rounded-md bg-stat-card-icon-background p-1.5">
<stat.icon className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
</span>
<div className="flex w-full flex-col gap-0.5">
<p className="w-full text-body-medium text-text-secondary">{stat.label}</p>
<div className="flex w-full flex-wrap items-center gap-2">
<p className="text-title-1-medium whitespace-nowrap text-text-primary">{stat.value}</p>
<Chip variant="bold" color={stat.deltaColor}>
{stat.delta}
</Chip>
</div>
</div>
</section>
);
}
function FooterStatCard({ stat }: { stat: Stat }) {
return (
<section className="flex min-w-0 flex-col rounded-2xl bg-background-secondary-default p-2">
{/* Icon tile + optional info glyph */}
<div className="flex w-full items-center justify-between gap-2.5 p-2">
<span
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-2lg bg-linear-to-b",
TILE_TONES[stat.tone ?? "blue"],
)}
>
<stat.icon className="size-5 shrink-0 text-white" aria-hidden />
</span>
{stat.hint && <StatHint label={stat.label} hint={stat.hint} />}
</div>
{/* Label sits directly over the number, as on the plain cards */}
<div className="flex flex-col gap-0.5 px-2 pt-2.5 pb-3.5">
<p className="truncate text-body-medium text-text-secondary">{stat.label}</p>
<p className="text-display-4-medium whitespace-nowrap text-text-primary tabular-nums">
{stat.value}
</p>
</div>
{/* Footer band: comparison caption + delta pill on an inner tile */}
<div className="mt-auto flex w-full items-center justify-between gap-2 rounded-2lg bg-background-inner-default py-1.5 pr-1.5 pl-2.5 shadow-card">
<p className="truncate text-body-regular text-text-secondary">
{stat.caption ?? "From last month"}
</p>
<DeltaPill delta={stat.delta} deltaColor={stat.deltaColor} />
</div>
</section>
);
}
export function StatCards({
variant = "plain",
stats,
count,
columns = 4,
className,
}: {
variant?: StatCardsVariant;
/** KPI cards to render. */
stats: Stat[];
/** How many KPI cards to render (from the start of the list). */
count?: number;
/** Columns at the widest breakpoint - 2 keeps the grid two-up for
* narrower hosts (docs previews, split layouts), 1 pins a single
* column at every width. */
columns?: 1 | 2 | 4;
className?: string;
}) {
const items = stats;
return (
<div
className={cx(
"grid w-full gap-4",
// The footer cards carry a display-size value, so they go one per
// row on phones where the plain cards still fit two up.
columns === 1
? "grid-cols-1"
: variant === "footer"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-2",
columns === 4 && (variant === "footer" ? "xl:grid-cols-4" : "lg:grid-cols-4"),
className,
)}
>
{items.slice(0, count ?? items.length).map((stat) =>
variant === "footer" ? (
<FooterStatCard key={stat.label} stat={stat} />
) : (
<PlainStatCard key={stat.label} stat={stat} />
),
)}
</div>
);
}"use client";
import type { ComponentType } from "react";
import {
RiArrowDownCircleFill,
RiArrowUpCircleFill,
RiIndeterminateCircleFill,
RiInformationFill,
} from "@remixicon/react";
import { Focusable } from "react-aria-components";
import { Chip } from "@/components/base/badges/chip";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type StatCardsVariant = "plain" | "footer";
/** Tint of the footer variant's gradient icon tile. */
export type StatTone = "blue" | "orange" | "purple" | "pink" | "sky" | "emerald";
export type Stat = {
icon: IconComponent;
label: string;
value: string;
delta: string;
deltaColor: "lime" | "rose" | "neutral";
/** Footer variant: icon tile tint (defaults to blue). */
tone?: StatTone;
/** Footer variant: comparison caption in the band ("From last month"). */
caption?: string;
/** Footer variant: shows an info glyph with this text on hover. */
hint?: string;
};
/** Gradient stops for the footer variant's icon tile, keyed by tone. */
const TILE_TONES: Record<StatTone, string> = {
blue: "from-blue-500 to-blue-600",
orange: "from-orange-400 to-orange-500",
purple: "from-purple-500 to-purple-600",
pink: "from-pink-500 to-pink-600",
sky: "from-sky-400 to-sky-500",
emerald: "from-emerald-500 to-emerald-600",
};
const DELTA_STYLES: Record<Stat["deltaColor"], { icon: IconComponent; className: string }> = {
lime: { icon: RiArrowUpCircleFill, className: "text-status-lime-text" },
rose: { icon: RiArrowDownCircleFill, className: "text-status-rose-text" },
neutral: { icon: RiIndeterminateCircleFill, className: "text-text-tertiary" },
};
/** White pill with a direction glyph — the footer band's delta readout. */
function DeltaPill({ delta, deltaColor }: Pick<Stat, "delta" | "deltaColor">) {
const { icon: Icon, className } = DELTA_STYLES[deltaColor];
return (
<span className="flex shrink-0 items-center gap-1 rounded-full border border-border-button-default bg-background-primary-default py-0.5 pr-2 pl-1 shadow-xs">
<Icon className={cx("size-4 shrink-0", className)} aria-hidden />
<span className={cx("text-body-medium whitespace-nowrap tabular-nums", className)}>
{delta}
</span>
</span>
);
}
/** Bare info glyph with a tooltip — the footer header's trailing control. */
function StatHint({ label, hint }: { label: string; hint: string }) {
return (
<TooltipTrigger delay={200}>
<Focusable>
<button
type="button"
aria-label={`About ${label}`}
className="flex shrink-0 cursor-pointer items-center justify-center rounded-full text-foreground-icon-secondary outline-none transition-colors duration-150 ease hover:text-foreground-icon-primary focus-visible:ring-2 focus-visible:ring-border-focus-ring"
>
<RiInformationFill className="size-5" aria-hidden />
</button>
</Focusable>
<Tooltip size="md">{hint}</Tooltip>
</TooltipTrigger>
);
}
function PlainStatCard({ stat }: { stat: Stat }) {
return (
<section className="flex h-[132px] min-w-0 flex-col items-start justify-between rounded-2xl bg-background-secondary-default p-4">
<span className="flex items-center rounded-md bg-stat-card-icon-background p-1.5">
<stat.icon className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
</span>
<div className="flex w-full flex-col gap-0.5">
<p className="w-full text-body-medium text-text-secondary">{stat.label}</p>
<div className="flex w-full flex-wrap items-center gap-2">
<p className="text-title-1-medium whitespace-nowrap text-text-primary">{stat.value}</p>
<Chip variant="bold" color={stat.deltaColor}>
{stat.delta}
</Chip>
</div>
</div>
</section>
);
}
function FooterStatCard({ stat }: { stat: Stat }) {
return (
<section className="flex min-w-0 flex-col rounded-2xl bg-background-secondary-default p-2">
{/* Icon tile + optional info glyph */}
<div className="flex w-full items-center justify-between gap-2.5 p-2">
<span
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-2lg bg-linear-to-b",
TILE_TONES[stat.tone ?? "blue"],
)}
>
<stat.icon className="size-5 shrink-0 text-white" aria-hidden />
</span>
{stat.hint && <StatHint label={stat.label} hint={stat.hint} />}
</div>
{/* Label sits directly over the number, as on the plain cards */}
<div className="flex flex-col gap-0.5 px-2 pt-2.5 pb-3.5">
<p className="truncate text-body-medium text-text-secondary">{stat.label}</p>
<p className="text-display-4-medium whitespace-nowrap text-text-primary tabular-nums">
{stat.value}
</p>
</div>
{/* Footer band: comparison caption + delta pill on an inner tile */}
<div className="mt-auto flex w-full items-center justify-between gap-2 rounded-2lg bg-background-inner-default py-1.5 pr-1.5 pl-2.5 shadow-card">
<p className="truncate text-body-regular text-text-secondary">
{stat.caption ?? "From last month"}
</p>
<DeltaPill delta={stat.delta} deltaColor={stat.deltaColor} />
</div>
</section>
);
}
export function StatCards({
variant = "plain",
stats,
count,
columns = 4,
className,
}: {
variant?: StatCardsVariant;
/** KPI cards to render. */
stats: Stat[];
/** How many KPI cards to render (from the start of the list). */
count?: number;
/** Columns at the widest breakpoint - 2 keeps the grid two-up for
* narrower hosts (docs previews, split layouts), 1 pins a single
* column at every width. */
columns?: 1 | 2 | 4;
className?: string;
}) {
const items = stats;
return (
<div
className={cx(
"grid w-full gap-4",
// The footer cards carry a display-size value, so they go one per
// row on phones where the plain cards still fit two up.
columns === 1
? "grid-cols-1"
: variant === "footer"
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-2",
columns === 4 && (variant === "footer" ? "xl:grid-cols-4" : "lg:grid-cols-4"),
className,
)}
>
{items.slice(0, count ?? items.length).map((stat) =>
variant === "footer" ? (
<FooterStatCard key={stat.label} stat={stat} />
) : (
<PlainStatCard key={stat.label} stat={stat} />
),
)}
</div>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
StatCards
| Prop | Type | Default | Description |
|---|---|---|---|
| statsrequired | Stat[] | — | KPI cards to render. |
| className | string | — | — |
| columns | 1 | 2 | 4 | 4 | Columns at the widest breakpoint - 2 keeps the grid two-up for narrower hosts (docs previews, split layouts), 1 pins a single column at every width. |
| count | number | — | How many KPI cards to render (from the start of the list). |
| variant | "plain" | "footer" | plain | — |