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.
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 }
pnpm dlx shadcn add @01orbital/statsnpx shadcn add @01orbital/statsyarn shadcn add @01orbital/statsbunx --bun shadcn add @01orbital/statsFree 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/sectionnpx shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/sectionyarn shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/sectionbunx --bun shadcn add @01orbital/eyebrow @01orbital/heading @01orbital/sectionimport { 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>
)
}Stats is built from 4 parts. In the example they nest like this:
StatGroup
└─ Stat
├─ StatValue
└─ StatLabelStatGroup: 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.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.
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.
The figure itself, such as a number or a percentage, in large type.
It accepts the HTML attributes of the element it renders.
The text that says what the figure measures.
It accepts the HTML attributes of the element it renders.
npm: class-variance-authority, cn. The CLI installs them for you.
Registry pieces it installs with it:
font-hanken-groteskgridSection
Page section frame: a container-query band at three paddings around a centred 80rem column, with background, stack or split header, title group, description, width-capped content, columns and actions.
Compare
Every Glow component side by side: whether it follows the theme or stays dark, how many parts and custom props it has, and whether it is free. Open one for its preview, API and install.