blocksection
Stats
A row of headline figures as a description list, with a spoken form for abbreviated values.
Also ships a motion variant: Stats (motion). The static one is the default.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/stats.jsonPulls in container, section, section-header, stat, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { Stats } from "@registry/sections/stats"
import { STATS } from "./content"
export function Demo() {
return (
<Stats
eyebrow="By the numbers"
title="What the checks actually cover"
items={STATS}
variant="card"
/>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* Stats — a row of headline figures.
*
* a11y: the figures are a `<dl>`, with each `Stat` contributing a `dt`/`dd`
* pair. A description list is the only markup that actually expresses "this
* number means this thing"; a grid of `<div>`s leaves a screen-reader user with
* a run of bare numbers. `Stat` puts the label before the value in the DOM for
* the same reason, and `srValue` exists so "1.2K" is read as "1200".
*
* Dependencies: react, @/lib/types, @/lib/utils,
* @/components/ui/container, @/components/ui/section, @/components/ui/section-header,
* @/components/ui/stat.
*/
import type { ElementType } from "react"
import type { ListSlotProps, SectionBaseProps } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Container } from "@/components/ui/container"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"
import { Stat, type StatItem } from "@/components/ui/stat"
export interface StatsProps extends SectionBaseProps, ListSlotProps {
items: StatItem[]
title?: string
eyebrow?: string
description?: string
size?: "sm" | "md" | "lg"
align?: "start" | "center"
/** `plain` sits on the background; `card` gives each figure a surface. */
variant?: "plain" | "card" | "divided"
}
export function Stats({
items,
title,
eyebrow,
description,
size = "md",
align = "center",
variant = "plain",
listAs,
itemAs,
headingLevel = 2,
as,
spacing = "md",
className,
id,
}: StatsProps) {
// The list slot must render a <dl>, not a <ul> — see the header.
const List = (listAs ?? "dl") as ElementType
const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
const columns =
items.length <= 2
? "sm:grid-cols-2"
: items.length === 3
? "sm:grid-cols-3"
: "sm:grid-cols-2 lg:grid-cols-4"
return (
<Section
as={as ?? (title ? "section" : "div")}
spacing={spacing}
labelledBy={titleId}
id={id}
className={className}
>
<Container className="flex flex-col gap-12">
{title ? (
<SectionHeader
align={align}
eyebrow={eyebrow}
title={title}
description={description}
headingLevel={headingLevel}
titleId={titleId}
/>
) : null}
<List
className={cn(
"grid gap-8",
columns,
variant === "divided" && "sm:divide-border sm:gap-0 sm:divide-x",
)}
>
{/*
`itemAs` becomes Stat's *own* wrapper rather than an element around
it. A <dl> permits at most one level of div between itself and a
dt/dd pair, so wrapping would produce invalid markup — which is
exactly what the axe run caught when it did.
*/}
{items.map((item) => (
<Stat
key={item.label}
{...item}
as={itemAs}
size={size}
align={align}
className={cn(
variant === "card" &&
"bg-card text-card-foreground rounded-xl border p-6",
variant === "divided" && "sm:px-8",
)}
/>
))}
</List>
</Container>
</Section>
)
}Props
StatsProps
Extends SectionBaseProps, ListSlotProps.
| Prop | Type | Default |
|---|---|---|
items*Required | StatItem[] | — |
title | string | — |
eyebrow | string | — |
description | string | — |
size | "sm" | "md" | "lg" | "md" |
align | "start" | "center" | "center" |
variant`plain` sits on the background; `card` gives each figure a surface. | "plain" | "card" | "divided" | "plain" |