- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
- Active webhooks
- 1.248Last 30 daysStatus
- Delivered
- 438Last 30 daysStatus
"use client";
import { Activity } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";
export function StatsCardDemo() {
return <StatsCard ariaLabel={"Webhook overview"} items={[
{
key: "metric-0",
title: "Active webhooks",
value: {"target":1248},
caption: "Last 30 days",
badge: {"label":"Status","variant":"secondary"},
icon: Activity
},
{
key: "metric-1",
title: "Delivered",
value: {"target":438},
caption: "Last 30 days",
badge: {"label":"Status","variant":"secondary"},
icon: Activity
}
]} />;
}Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/stats-card.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- cn
- lucide-react
- motion@^12.38.0
$ pnpm add cn lucide-react motion@^12.38.0Usage
"use client";
import { Activity } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";
export function Demo() {
return (
<StatsCard
ariaLabel="Delivery overview"
items={[
{
key: "delivered",
title: "Delivered",
value: { target: 1248 },
caption: "Last 30 days",
badge: { label: "Healthy", variant: "secondary" },
icon: Activity,
},
]}
/>
);
}Requires React 19, Tailwind CSS 4 with shadcn tokens, Motion 12.38+, lucide-react, and cn. Use your configured UI alias if it differs. Installation includes stats-card.tsx and its colocated supporting stats-card-badge.tsx and stats-card-layout.ts; it does not install or overwrite your existing Badge component. No providers or website-specific styles are required. Copying the displayed Source alone is insufficient for manual installation: use the CLI command above to receive all three files.
Examples
One metric
Titles, values, captions, and badges wrap on narrow screens. The icon remains decorative.
- Delivered
- 1.248Last 30 daysHealthy
"use client";
import { Activity } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";
export const StatsCardDemo = () => (
<StatsCard
ariaLabel="Delivery overview"
items={[
{
badge: { label: "Healthy", variant: "secondary" },
caption: "Last 30 days",
icon: Activity,
key: "delivered",
title: "Delivered",
value: { target: 1248 },
},
]}
/>
);Formatting and exact values
Use value.target for finite numbers and value.format for application-specific formatting. The default formatter uses id-ID with zero fractional digits. Formatters receive intermediate fractional numbers during animation, so round or use Intl.NumberFormat as appropriate.
Use value.display for exact preformatted strings, including financial values beyond JavaScript's safe integer range. StatsCard does not parse, coerce, or animate these values. Numeric animation is presentation only, not financial arithmetic or a guarantee of integer precision. null, NaN, and infinite targets show —; zero and negative targets are valid. Display and target modes are mutually exclusive in TypeScript.
- Balance change
- -Rp 125.000Compared with last month
- Exact payment volume
- Rp9.007.199.254.740.993Preformatted by the application
"use client";
import { Activity, Wallet } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";
const currency = new Intl.NumberFormat("id-ID", {
currency: "IDR",
maximumFractionDigits: 0,
style: "currency",
});
export const StatsCardValues = () => (
<StatsCard
ariaLabel="Payment overview"
items={[
{
caption: "Compared with last month",
icon: Activity,
key: "change",
title: "Balance change",
value: { format: currency.format, target: -125_000 },
},
{
caption: "Preformatted by the application",
icon: Wallet,
key: "volume",
title: "Exact payment volume",
value: { display: "Rp9.007.199.254.740.993" },
},
]}
/>
);Missing data
Use target: null when a measurement is unavailable. An optional badge can explain why no value is shown.
- Success rate
- —Awaiting data
"use client";
import { CircleCheck } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";
export const StatsCardMissing = () => (
<StatsCard
ariaLabel="Success rate overview"
items={[
{
badge: { label: "Awaiting data", variant: "outline" },
icon: CircleCheck,
key: "rate",
title: "Success rate",
value: { target: null },
},
]}
/>
);Updating values
Keep each item's key stable. The first numeric value counts from zero; subsequent changes animate from the current displayed number over 1.2 seconds. A new target interrupts the previous transition, and unmounting stops it. Reset in this example changes the target, not the component's identity.
- Delivered
- 1.248Stable key keeps updates continuous
"use client";
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { StatsCard } from "@/components/ui/stats-card";
export const StatsCardUpdating = () => {
const [target, setTarget] = useState(1248);
return (
<div className="flex w-full flex-col items-start gap-4">
<StatsCard
ariaLabel="Live deliveries"
items={[
{
caption: "Stable key keeps updates continuous",
icon: Activity,
key: "delivered",
title: "Delivered",
value: { target },
},
]}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setTarget((current) => current + 100)}
>
Add 100
</Button>
<Button
variant="outline"
onClick={() => setTarget((current) => current - 100)}
>
Subtract 100
</Button>
<Button variant="outline" onClick={() => setTarget(1248)}>
Reset value
</Button>
</div>
</div>
);
};Behavior & Accessibility
- The outer group requires
ariaLabel. Metrics use a definition list, with titles indtand values and supporting content indd. - Screen readers receive the final formatted target, not every animated frame. Values are not a live region; applications should announce important asynchronous updates separately.
- Reduced-motion preferences show the final number immediately. Server rendering also includes the final value before the client starts animation.
- One metric fills the width. Mobile stacks. Exactly three metrics use three columns from
sm; other counts balance across at most two columns atsmand four atlg. Desktop five becomes 3+2, six becomes 3+3, seven becomes 4+3, and every row fills the available width. Separators follow actual row/column ends. Use the playground's metric count control or FiveMetrics/SixMetrics/SevenMetrics stories to inspect these cases. An empty array renders an empty group, not a loading or empty-state message. - The API is deliberately restricted to
ariaLabel,items, andclassName. It does not forward native attributes, refs, or event handlers. Values, captions, and badge labels accept React nodes; avoid adding interactive content inside the animated numeric value. - Styling uses
card,card-foreground,border,muted-foreground,primary,primary-foreground,secondary,secondary-foreground,destructive, anddestructive-foregroundtokens. Light and dark themes inherit your application setup.
Storybook Stories (Optional)
Install StatsCard first, then add its portable stories to an existing React Storybook:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/stats-card-stories.json
This installs only stats-card.stories.tsx beside the component in your configured UI directory. It does not reinstall StatsCard, add Storybook packages, or change .storybook/. Review overwrite prompts for existing stories. Supply @storybook/react matching your existing Storybook packages; stories are typechecked and composed against version 10.6.1. No addons are required for Canvas and Controls.
Include ../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx) in your stories patterns, adjusting for your UI directory. Import your application's Tailwind/global stylesheet in .storybook/preview.ts, include the UI directory in Tailwind source scanning, and apply your dark-theme selector inside the preview iframe. Stories inherit your theme and decorators. Numeric transitions honor the preview's reduced-motion media preference.
Origin
Adapted from Valcentra's StatsCard implementation, including its data API, numeric animation, and accessible definition-list structure. Vandor UI adds distributable badge support, wrapping for long content, and dividers for arbitrary item counts. The component does not depend on the Valcentra application.
Props
| Prop | Type / Description | Default |
|---|---|---|
| ariaLabel | stringAccessible name of the statistics group. | Required |
| className | stringClasses merged onto the outer group. | Not set |
| item.badge | { label: ReactNode; variant?: 'default' | 'secondary' | 'destructive' | 'outline'; className?: string }Optional non-interactive badge beneath the value. Classes affect only this badge. | Not set (variant: secondary) |
| item.caption | ReactNodeOptional supporting content beneath the value. | Not set |
| item.icon | LucideIconDecorative icon component, hidden from assistive technology. | Required |
| item.key | stringStable unique identifier. Preserve it across updates to animate from the current value. | Required |
| item.title | stringVisible metric label in a definition list. | Required |
| item.value | StatsCardValueEither { target: number | null, format?: (value: number) => string } or { display: ReactNode }, not both. | Required |
| item.value.format | (value: number) => stringFormats numeric animation frames and the accessible final value. Must accept fractional values. Use display for exact financial strings. | Intl.NumberFormat("id-ID", { maximumFractionDigits: 0 }).format |
| items | readonly StatsCardItem[]Ordered metrics. Each item requires a unique key, title, value, and Lucide icon. Empty arrays render an empty group. | Required |
StatsCard supports only ariaLabel, items, and className. It does not forward native attributes, refs, or event handlers.
Source
"use client";
// Adapted from Valcentra's StatsCard. Uses local supporting styles, not website UI.
import { cn } from "cn";
import type { LucideIcon } from "lucide-react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { StatsCardBadge } from "./stats-card-badge";
import type { StatsCardBadgeVariant } from "./stats-card-badge";
import { getStatsCardLayout } from "./stats-card-layout";
export type StatsCardValue =
| {
target: number | null;
format?: (value: number) => string;
display?: never;
}
| { display: ReactNode; target?: never; format?: never };
export interface StatsCardItem {
key: string;
title: string;
value: StatsCardValue;
icon: LucideIcon;
caption?: ReactNode;
badge?: {
label: ReactNode;
variant?: StatsCardBadgeVariant;
className?: string;
};
}
export interface StatsCardProps {
ariaLabel: string;
items: readonly StatsCardItem[];
className?: string;
}
const defaultNumberFormat = new Intl.NumberFormat("id-ID", {
maximumFractionDigits: 0,
}).format;
const AnimatedStatsValue = ({
value,
}: {
value: Extract<StatsCardValue, { target: number | null }>;
}) => {
const target =
value.target !== null && Number.isFinite(value.target)
? value.target
: null;
const format = value.format ?? defaultNumberFormat;
const formatRef = useRef(format);
formatRef.current = format;
const motionValue = useMotionValue(0);
const formattedValue = useTransform(motionValue, (current) =>
formatRef.current(current)
);
const shouldReduceMotion = useReducedMotion();
const [hasMounted, setHasMounted] = useState(false);
const hasStarted = useRef(false);
const finalValue = target === null ? "—" : format(target);
useEffect(() => {
if (target === null) {
motionValue.set(0);
setHasMounted(true);
return;
}
if (shouldReduceMotion) {
motionValue.set(target);
hasStarted.current = true;
setHasMounted(true);
return;
}
if (!hasStarted.current) {
motionValue.set(0);
hasStarted.current = true;
}
const controls = animate(motionValue, target, {
duration: 1.2,
ease: "easeOut",
});
setHasMounted(true);
return () => controls.stop();
}, [motionValue, shouldReduceMotion, target]);
if (!hasMounted || shouldReduceMotion || target === null) {
return <span>{finalValue}</span>;
}
return (
<>
<span className="sr-only">{finalValue}</span>
<motion.span aria-hidden="true">{formattedValue}</motion.span>
</>
);
};
const StatsValue = ({ value }: { value: StatsCardValue }) =>
"display" in value ? (
<span>{value.display}</span>
) : (
<AnimatedStatsValue value={value} />
);
export const StatsCard = ({ ariaLabel, items, className }: StatsCardProps) => {
const tabletColumns = items.length === 3 ? 3 : 2;
const tablet = getStatsCardLayout(items.length, tabletColumns);
const desktop = getStatsCardLayout(items.length, 4);
return (
<div
data-slot="stats-card"
role="group"
aria-label={ariaLabel}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
className
)}
>
<dl className={cn("grid grid-cols-12")}>
{items.map((item, index) => {
const Icon = item.icon;
const hasCaption =
item.caption !== null && item.caption !== undefined;
const tabletCell = tablet[index];
const desktopCell = desktop[index];
const spanClass: Record<number, string> = {
1: "sm:col-span-1",
12: "sm:col-span-12",
2: "sm:col-span-2",
3: "sm:col-span-3",
4: "sm:col-span-4",
6: "sm:col-span-6",
};
const desktopSpanClass: Record<number, string> = {
12: "lg:col-span-12",
3: "lg:col-span-3",
4: "lg:col-span-4",
6: "lg:col-span-6",
};
return (
<div
key={item.key}
data-slot="stats-card-item"
data-tablet-columns={tabletCell?.columns}
data-desktop-columns={desktopCell?.columns}
className={cn(
"col-span-12 min-w-0 border-border",
tabletCell && spanClass[tabletCell.columns],
desktopCell && desktopSpanClass[desktopCell.columns],
index < items.length - 1 && "border-b",
tabletCell?.lastRow ? "sm:border-b-0" : "sm:border-b",
tabletCell?.lastColumn ? "sm:border-e-0" : "sm:border-e",
desktopCell?.lastRow ? "lg:border-b-0" : "lg:border-b",
desktopCell?.lastColumn ? "lg:border-e-0" : "lg:border-e"
)}
>
<div className="flex h-full items-start justify-between gap-4 p-5">
<div className="flex min-w-0 flex-1 flex-col gap-3">
<dt className="text-sm font-medium wrap-anywhere">
{item.title}
</dt>
<dd className="min-w-0">
<div className="text-2xl font-semibold tracking-tight tabular-nums wrap-anywhere">
<StatsValue value={item.value} />
</div>
{(hasCaption || item.badge) && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{hasCaption && (
<div className="text-xs text-muted-foreground wrap-anywhere">
{item.caption}
</div>
)}
{item.badge && (
<StatsCardBadge
className={item.badge.className}
variant={item.badge.variant}
>
{item.badge.label}
</StatsCardBadge>
)}
</div>
)}
</dd>
</div>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground [&_svg]:size-4">
<Icon aria-hidden="true" />
</div>
</div>
</div>
);
})}
</dl>
</div>
);
};