Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
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

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/stats.json

Pulls in container, section, section-header, stat, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/stats.tsx
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.

components/sections/stats.tsx
/**
 * 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.

Props for StatsProps
PropTypeDefault
items*RequiredStatItem[]
titlestring
eyebrowstring
descriptionstring
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"