- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Active members
8,240
+8.4%Projects
326
-2.1%Sessions
12,805
0%Balance
$42,180
+4.2%Active members
8,240
From last month
+8.4%Projects
326
From last month
-2.1%Sessions
12,805
From last month
0%Balance
$42,180
From last month
+4.2%Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/stat-cards-01
Pass your own metrics through the stats prop. Use variant="footer" for the larger layout and optional hints.
Source
"use client";
import {
RiArrowDownCircleFill,
RiArrowUpCircleFill,
RiIndeterminateCircleFill,
RiInformationFill,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Reveal } from "@/components/ui/reveal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type StatTone =
| "blue"
| "orange"
| "purple"
| "pink"
| "sky"
| "emerald";
export interface Stat {
icon: React.ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
label: string;
value: string;
delta: string;
deltaColor: "lime" | "rose" | "neutral";
tone?: StatTone;
caption?: string;
hint?: string;
}
const tones: Record<StatTone, string> = {
blue: "bg-[image:var(--stat-tile-blue,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
orange:
"bg-[image:var(--stat-tile-orange,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
purple:
"bg-[image:var(--stat-tile-purple,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
pink: "bg-[image:var(--stat-tile-pink,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
sky: "bg-[image:var(--stat-tile-sky,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
emerald:
"bg-[image:var(--stat-tile-emerald,linear-gradient(to_bottom,var(--primary),var(--primary)))]",
};
function StatValue({ value }: { value: string }) {
const match = value.match(/^(.*?)(-?[\d,]+(?:\.\d+)?)([^\d]*)$/);
const target = match ? Number(match[2].replaceAll(",", "")) : Number.NaN;
const [display, setDisplay] = React.useState(target);
const previous = React.useRef(target);
React.useEffect(() => {
const from = previous.current;
if (!Number.isFinite(target)) {
return;
}
if (
!Number.isFinite(from) ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
previous.current = target;
setDisplay(target);
return;
}
if (from === target) {
return;
}
const start = performance.now();
let frame = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / 320);
const next = from + (target - from) * (1 - (1 - t) ** 3);
previous.current = next;
setDisplay(next);
if (t < 1) {
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target]);
if (!match || !Number.isFinite(display)) {
return value;
}
const decimals = (match[2].split(".")[1] ?? "").length;
const number = display.toLocaleString("en-US", {
useGrouping: match[2].includes(","),
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return (
<>
{match[1]}
{number}
{match[3]}
</>
);
}
function StatHint({
label,
hint,
popupClassName,
}: {
label: string;
hint: string;
popupClassName?: string;
}) {
const [open, setOpen] = React.useState(false);
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger
render={
<Button
variant="ghost-muted"
size="icon-2xs"
shape="pill"
type="button"
aria-label={`About ${label}`}
onClick={() => setOpen((value) => !value)}
/>
}
>
<RiInformationFill className="size-5" aria-hidden />
</TooltipTrigger>
<TooltipContent size="default" className={popupClassName}>
{hint}
</TooltipContent>
</Tooltip>
);
}
export function StatCards({
variant = "plain",
stats,
count,
columns = 4,
className,
popupClassName,
}: {
variant?: "plain" | "footer";
stats: Stat[];
count?: number;
columns?: 1 | 2 | 4;
className?: string;
popupClassName?: string;
}) {
return (
<TooltipProvider delay={200}>
<div
className={cn(
"grid w-full gap-4",
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
)}
>
{stats.slice(0, count ?? stats.length).map((stat, index) => {
const DeltaIcon =
stat.deltaColor === "lime"
? RiArrowUpCircleFill
: stat.deltaColor === "rose"
? RiArrowDownCircleFill
: RiIndeterminateCircleFill;
return variant === "plain" ? (
<Reveal
key={stat.label}
index={index}
render={
<Card
radius={16}
variant="muted"
// oxlint-disable-next-line shadcn/no-restyle -- reference: 132px card, icon and text sit at 16px insets with no extra gap
className="min-h-[132px] min-w-0 items-start justify-between gap-0"
/>
}
>
<span className="mx-4 flex items-center rounded-[8px] bg-surface-card p-1.5">
<stat.icon
className="size-5 shrink-0 text-foreground"
aria-hidden
/>
</span>
<CardContent className="w-full gap-0.5 max-sm:min-h-22">
<p className="w-full text-sm leading-5 font-medium text-muted-foreground">
{stat.label}
</p>
<div className="flex w-full flex-wrap items-center gap-2">
<p className="min-w-0 text-2xl leading-8.5 font-medium break-all text-foreground">
<StatValue value={stat.value} />
</p>
<Badge variant="label" tone={stat.deltaColor}>
{stat.delta}
</Badge>
</div>
</CardContent>
</Reveal>
) : (
<Reveal
key={stat.label}
index={index}
render={
<Card
radius={16}
variant="muted"
size="xs"
className="row-span-3 grid min-w-0 grid-rows-subgrid"
/>
}
>
<CardHeader className="flex items-start justify-between">
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] text-(color:--stat-tile-foreground,var(--primary-foreground))",
tones[stat.tone ?? "blue"]
)}
>
<stat.icon className="size-5 shrink-0" aria-hidden />
</span>
{stat.hint && (
<StatHint
label={stat.label}
hint={stat.hint}
popupClassName={popupClassName}
/>
)}
</CardHeader>
<CardHeader className="min-w-0">
<CardTitle variant="secondary" className="break-words">
{stat.label}
</CardTitle>
<p className="min-w-0 text-3xl leading-11 font-medium break-all text-foreground tabular-nums">
<StatValue value={stat.value} />
</p>
</CardHeader>
<CardContent>
<Card variant="raised" size="xs" radius={10}>
<CardContent className="flex-row items-center justify-between gap-2">
<p className="min-w-0 text-sm leading-5 break-words text-muted-foreground">
{stat.caption ?? "From last month"}
</p>
<Badge
variant="label"
tone={
stat.deltaColor === "neutral" ? "soft" : stat.deltaColor
}
shape="pill"
tabular
// oxlint-disable-next-line shadcn/no-restyle -- reference: footer delta pill is 4px icon gap, 4px left and 8px right padding (label variant is gap-0 px-1.5)
className="gap-1 pe-2 ps-1"
>
<span className="flex shrink-0" aria-hidden>
<DeltaIcon className="size-4" />
</span>
{stat.delta}
</Badge>
</CardContent>
</Card>
</CardContent>
</Reveal>
);
})}
</div>
</TooltipProvider>
);
} On this page