Editorial

Stats

Statistic list on Grid: each stat pairs a large value in the Hanken Grotesk display face with a label, shown plain, on a filled card panel, or with a rule down its left side.

Free, MIT licenseOpen in Browse
import type { ComponentProps } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Grid, type GridProps } from "@/components/editorial/grid"

/**
 * A list of `Stat` items laid out by `Grid`, rendered as a `ul`. It defaults
 * to 3 columns and the large gap. Like `Grid`, its columns follow the width
 * it is given, so size its parent to narrow it. `className` lands on the
 * list.
 */
function StatGroup({
  cols = 3,
  gap = "lg",
  ...props
}: ComponentProps<"ul"> & {
  /**
   * The most columns, 3 by default. They step up as the list's space
   * widens, the way `Grid`'s do.
   */
  cols?: GridProps["cols"]
  /**
   * Space between stats: `xs` 8px, `sm` 16px, `md` 24px, `lg` 40px, `xl` 64px, `px` 1px, `none` 0. `lg`
   * is the default.
   */
  gap?: GridProps["gap"]
}) {
  return (
    <Grid
      data-slot="stat-group"
      render={<ul role="list" />}
      cols={cols}
      gap={gap}
      // Grid's props are typed for its default div; this group renders a list.
      {...(props as Omit<GridProps, "cols" | "gap">)}
    />
  )
}

// card sets the stat on a filled panel; rule sets a line down its left side.
const statVariants = cva("grid content-start gap-2", {
  variants: {
    /**
     * The stat style. `default` (the default) adds nothing. `card` sets it
     * on a `rounded-xl` panel with 32px padding, filled with `muted`, or
     * with `card` and `card-foreground` text in dark mode. `rule` draws a
     * 1px line in `border` down its left side, 24px from the text.
     */
    variant: {
      default: "",
      card: "rounded-xl bg-muted p-8 dark:bg-card dark:text-card-foreground",
      rule: "border-l border-border pl-6",
    },
  },
  defaultVariants: { variant: "default" },
})

/**
 * One figure and its label, as an `li` inside `StatGroup`. Holds a
 * `StatValue` and a `StatLabel`, stacked with 8px gaps.
 *
 * `card` sets it on a filled, rounded panel with 32px padding. `rule` draws a
 * line down its left side.
 */
function Stat({
  className,
  variant = "default",
  ...props
}: ComponentProps<"li"> & VariantProps<typeof statVariants>) {
  return (
    <li
      data-slot="stat"
      data-variant={variant}
      className={cn(statVariants({ variant }), className)}
      {...props}
    />
  )
}

/** The figure itself, such as a number or a percentage, in large type. */
function StatValue({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="stat-value"
      className={cn("font-display text-[2rem]/10 tracking-tight", className)}
      {...props}
    />
  )
}

/** The text that says what the figure measures. */
function StatLabel({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="stat-label"
      className={cn("text-base/7 text-pretty", className)}
      {...props}
    />
  )
}

export { StatGroup, Stat, StatValue, StatLabel }

Installation

pnpm dlx shadcn add @01orbital/stats
npx shadcn add @01orbital/stats
yarn shadcn add @01orbital/stats
bunx --bun shadcn add @01orbital/stats

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/stats.tsx, as source you own and edit.

The example also uses eyebrow, heading and section. Install them too:

pnpm dlx shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/section
npx shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/section
yarn shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/section
bunx --bun shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/section

Usage

import { Eyebrow } from "@/components/editorial/eyebrow"
import { Heading } from "@/components/editorial/heading"
import {
  Section,
  SectionContent,
  SectionDescription,
  SectionHeader,
  SectionTitleGroup,
} from "@/components/editorial/section"
import {
  Stat,
  StatGroup,
  StatLabel,
  StatValue,
} from "@/components/editorial/stats"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  eyebrow: "By the numbers",
  title: "One registry, kept coherent as it grows",
  body: "Every piece is reviewed against the same tokens and the same rules, so the count climbs without the kit sprawling.",
} as const

const ITEMS = [
  { value: "828", label: "Items in the registry" },
  { value: "73", label: "Painted scenes" },
  { value: "76", label: "Orbital palettes" },
] as const

// The parts in the section they are built for: a header, then the row.
export default function StatsDemo() {
  return (
    <Section>
      <SectionHeader>
        <SectionTitleGroup>
          <Eyebrow>{COPY.eyebrow}</Eyebrow>
          <Heading>{COPY.title}</Heading>
        </SectionTitleGroup>
        <SectionDescription>{COPY.body}</SectionDescription>
      </SectionHeader>
      <SectionContent>
        <StatGroup cols={3}>
          {ITEMS.map(({ value, label }) => (
            <Stat key={label}>
              <StatValue>{value}</StatValue>
              <StatLabel>{label}</StatLabel>
            </Stat>
          ))}
        </StatGroup>
      </SectionContent>
    </Section>
  )
}

Anatomy

Stats is built from 4 parts. In the example they nest like this:

StatGroup
└─ Stat
   ├─ StatValue
   └─ StatLabel
  • StatGroup: A list of Stat items laid out by Grid, rendered as a ul. It defaults to 3 columns and the large gap. Like Grid, its columns follow the width it is given, so size its parent to narrow it. className lands on the list.
  • Stat: One figure and its label, as an li inside StatGroup. Holds a StatValue and a StatLabel, stacked with 8px gaps.
  • StatValue: The figure itself, such as a number or a percentage, in large type.
  • StatLabel: The text that says what the figure measures.

API reference

StatGroup

A list of Stat items laid out by Grid, rendered as a ul. It defaults to 3 columns and the large gap. Like Grid, its columns follow the width it is given, so size its parent to narrow it. className lands on the list.

Prop

Type

It also accepts the HTML attributes of the element it renders.

Stat

One figure and its label, as an li inside StatGroup. Holds a StatValue and a StatLabel, stacked with 8px gaps.

card sets it on a filled, rounded panel with 32px padding. rule draws a line down its left side.

Prop

Type

It also accepts the HTML attributes of the element it renders.

StatValue

The figure itself, such as a number or a percentage, in large type.

It accepts the HTML attributes of the element it renders.

StatLabel

The text that says what the figure measures.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • font-hanken-grotesk
  • grid