Browse the docs
BlocksNo. 103 of 109
Stats
A row of key figures that count up when they scroll into view, under a centred section header.
Installation
pnpm dlx shadcn@latest add @corsair-ui/statsNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsstatuse-in-viewuse-entrancenumber-ticker
- npm
clsxlucide-reacttailwind-merge
Examples
Default
Numbers count up once when they scroll into view.
Cards
variant="cards" puts each figure in a bordered box.
API
| Prop | Type | Default |
|---|---|---|
eyebrowSmall line above the title; null hides it. | ReactNode | "By the numbers" |
title / descriptionThe header; the title is an h2. null hides either. | ReactNode | example copy |
statsEach has label and value (a number counts up), and optionally id, prefix, suffix, format (Intl.NumberFormat options), caption and trend. | StatsItem[] | four examples |
variantPlain figures, or each in a bordered box. Exposed as data-variant. | "plain" | "cards" | "plain" |
localeLocale for numeric values; defaults to NumberTicker's. | string | — |
trendLabelsRead before a caption with a trend, since the arrow is only a picture. | Partial<Record<StatTrend, string>> | "Increase" / "Decrease" / "No change" |
Accessibility
- Built on StatGroup, a description list, so each value is read with its label. The title is an h2.
- Screen readers only hear the final number, never the moving digits.
- Trend arrows are hidden and replaced by trendLabels. With prefers-reduced-motion the final value shows at once.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Works in server components
Source
1 file, installed as source you own
stats.tsxView on GitHubimport type { ComponentProps, ReactNode } from "react";
import { cn } from "@/registry/default/lib/utils";
import { NumberTicker } from "@/registry/default/ui/number-ticker";
import {
Stat,
StatCaption,
StatGroup,
StatLabel,
StatValue,
type StatTrend,
} from "@/registry/default/ui/stat";
interface StatsItem {
/** Stable React key. Falls back to the index. */
id?: string;
label: ReactNode;
/** A number counts up when it scrolls into view; anything else renders as is. */
value: number | ReactNode;
/** Shown before the value, outside the counting number ("$"). */
prefix?: ReactNode;
/** Shown after the value, outside the counting number ("+", "%"). */
suffix?: ReactNode;
/** `Intl.NumberFormat` options for a numeric value: compact notation, digits, currency… */
format?: Intl.NumberFormatOptions;
/** A note under the value: a comparison, a period. */
caption?: ReactNode;
/** Adds an arrow for the direction of change next to the caption. */
trend?: StatTrend;
}
type StatsVariant = "plain" | "cards";
interface StatsProps extends Omit<ComponentProps<"section">, "title"> {
/** Small line above the title; pass `null` to hide it. */
eyebrow?: ReactNode;
/** Section title, rendered in an `<h2>`. */
title?: ReactNode;
description?: ReactNode;
stats?: StatsItem[];
/** Plain figures, or each figure in a bordered box. */
variant?: StatsVariant;
/** Locale for numeric values. Defaults to the one NumberTicker uses. */
locale?: string;
/** Read to screen readers before a caption with a trend, since the arrow is only a picture. */
trendLabels?: Partial<Record<StatTrend, string>>;
}
const defaultTrendLabels: Record<StatTrend, string> = {
up: "Increase",
down: "Decrease",
flat: "No change",
};
const defaultStats: StatsItem[] = [
{
id: "teams",
label: "Teams on Acme",
value: 12000,
suffix: "+",
caption: "18% more than last year",
trend: "up",
},
{ id: "uptime", label: "Uptime", value: 99.9, suffix: "%", caption: "Over the last 12 months" },
{
id: "requests",
label: "Requests a day",
value: 150,
suffix: "M",
caption: "Handled across 30 regions",
},
{
id: "response",
label: "Median response",
value: 42,
suffix: " ms",
caption: "Measured at the edge",
},
];
/**
* A row of key figures under an optional centred header. Built on
* StatGroup and Stat, so each value is read together with its label (a
* description list). `<Stats />` renders four example figures.
*
* A numeric `value` uses NumberTicker: it counts up once when it scrolls
* into view and shows the final value at once with `prefers-reduced-motion`.
* `prefix` and `suffix` sit outside the counting number; `format` and
* `locale` control how the number is written. Any other value (a string,
* an element) renders as is. Two columns on mobile, four on large screens;
* `variant="cards"` puts each figure in a bordered box. The variant is
* exposed as `data-variant`.
*
* Accessibility: screen readers only hear the final number, never the
* moving digits. Trend arrows are hidden and replaced by `trendLabels`.
*
* @example
* <Stats
* title="Growing every day"
* variant="cards"
* stats={[
* { label: "Revenue", value: 48200, prefix: "$", caption: "12% since last month", trend: "up" },
* { label: "Customers", value: 1200, suffix: "+" },
* { label: "Rating", value: "4.9 / 5" },
* ]}
* />
*/
function Stats({
eyebrow = "By the numbers",
title = "Trusted by teams that move fast",
description = "Acme runs quietly in the background while thousands of teams ship every day.",
stats = defaultStats,
variant = "plain",
locale,
trendLabels,
className,
...props
}: StatsProps) {
const hasHeader = eyebrow != null || title != null || description != null;
const labels = { ...defaultTrendLabels, ...trendLabels };
return (
<section
data-slot="stats"
data-variant={variant}
className={cn("py-16 sm:py-24", className)}
{...props}
>
<div data-slot="stats-container" className="mx-auto w-full max-w-6xl px-4 sm:px-6">
{hasHeader ? (
<div data-slot="stats-header" className="mx-auto max-w-2xl text-center">
{eyebrow != null ? (
<p data-slot="stats-eyebrow" className="text-muted-foreground text-sm font-medium">
{eyebrow}
</p>
) : null}
{title != null ? (
<h2
data-slot="stats-title"
className={cn(
"text-3xl font-semibold tracking-tight text-balance sm:text-4xl",
eyebrow != null && "mt-2"
)}
>
{title}
</h2>
) : null}
{description != null ? (
<p
data-slot="stats-description"
className="text-muted-foreground mt-4 text-lg text-pretty"
>
{description}
</p>
) : null}
</div>
) : null}
<StatGroup
className={cn(
"grid-cols-2 gap-x-6 gap-y-10 lg:grid-cols-4",
variant === "cards" && "gap-y-6",
hasHeader && "mt-12"
)}
>
{stats.map((stat, index) => (
<Stat
key={stat.id ?? index}
className={cn(
"min-w-0 gap-2",
variant === "cards" && "bg-card rounded-xl border p-6"
)}
>
<StatLabel>{stat.label}</StatLabel>
<StatValue className="text-3xl break-words sm:text-4xl">
{typeof stat.value === "number" ? (
<>
{stat.prefix != null ? (
<span data-slot="stats-prefix">{stat.prefix}</span>
) : null}
<NumberTicker value={stat.value} format={stat.format} locale={locale} />
{stat.suffix != null ? (
<span data-slot="stats-suffix">{stat.suffix}</span>
) : null}
</>
) : (
<>
{stat.prefix}
{stat.value}
{stat.suffix}
</>
)}
</StatValue>
{stat.caption != null || stat.trend ? (
<StatCaption
trend={stat.trend}
trendLabel={stat.trend ? labels[stat.trend] : undefined}
className="text-sm"
>
{stat.caption}
</StatCaption>
) : null}
</Stat>
))}
</StatGroup>
</div>
</section>
);
}
export { Stats, type StatsItem, type StatsProps, type StatsVariant };