one number that matters as a tile: its name, the figure, how it moved, its trend
npx shadcn add @ag/statAlready installed? Preview an update
npx shadcn add @ag/stat --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
Stat, a dashboard's tile for one number, in parts written in the order you want them:StatLabel,StatValue(32 px, tabular, never wrapping;value={n}says a count short, "1.3B", with the exact count on hover and to a screen reader),StatChange,StatNoteandStatChart(a 40 px slot a sparkline fills).StatChangetakes a signedvalue, said as a signed share or in your words (children), toned by whether its direction is good (good:up,downfor errors and latency,none), "better" or "worse" said to a screen reader.shareChange(previous, current)works out the share, null when there was nothing before. Alone it is a panel, plain or glass; inside a frame it draws none (frameoverrides).
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import { Sparkline } from "@/registry/base-nova/ui/sparkline"
import {
Stat,
StatChange,
StatChart,
StatLabel,
StatNote,
StatValue,
shareChange,
} from "@/registry/base-nova/ui/stat"
// #region series
/** Thirty days of a site, oldest first. */
const VISITS = [
1210, 1342, 1288, 1404, 1520, 1187, 1033, 1460, 1588, 1612, 1549, 1702, 1390,
1201, 1655, 1790, 1832, 1760, 1904, 1611, 1480, 1960, 2044, 2112, 1987, 2230,
1906, 1784, 2311, 2398,
]
const LATENCY = [
212, 205, 219, 224, 208, 199, 214, 231, 226, 218, 204, 196, 201, 209, 222,
236, 229, 215, 207, 198, 203, 211, 219, 227, 233, 224, 213, 206, 201, 197,
]
// #endregion
// #region visits
function Visits() {
return (
<Stat className="w-64">
<StatLabel>visits</StatLabel>
<StatValue value={48210}>
<StatChange value={0.124} />
</StatValue>
<StatNote>last 30 days, against the 30 before</StatNote>
<StatChart>
<Sparkline values={VISITS} label="visits by day" area />
</StatChart>
</Stat>
)
}
// #endregion
// #region row
/** Four numbers a morning starts with. Errors and latency should fall, so their
* tone turns: errors down 40% is good news, latency up 6% is not. */
function Row() {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Stat>
<StatLabel>revenue</StatLabel>
<StatValue>
€12.4k <StatChange value={0.082} />
</StatValue>
<StatNote>this month</StatNote>
</Stat>
<Stat>
<StatLabel>signups</StatLabel>
<StatValue value={312}>
<StatChange value={-0.064} />
</StatValue>
<StatNote>this month</StatNote>
</Stat>
<Stat>
<StatLabel>errors</StatLabel>
<StatValue value={18}>
<StatChange value={-0.4} good="down" />
</StatValue>
<StatNote>last 7 days</StatNote>
</Stat>
<Stat>
<StatLabel>p95 latency</StatLabel>
<StatValue>
214 ms <StatChange value={0.06} good="down" />
</StatValue>
<StatNote>last 7 days</StatNote>
<StatChart>
<Sparkline
values={LATENCY}
label="p95 latency by day, ms"
min={180}
max={240}
/>
</StatChart>
</Stat>
</div>
)
}
// #endregion
// #region panel
/** Several stats in one panel: inside a frame each draws no tile of its own. */
function Overview() {
return (
<Panel title="xtldr" description="the bot, this week">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-4">
<Stat>
<StatLabel>contacts</StatLabel>
<StatValue value={2431} />
<StatChange value={0.031} />
</Stat>
<Stat>
<StatLabel>weekly active</StatLabel>
<StatValue value={604} />
<StatChange value={-0.012} />
</Stat>
<Stat>
<StatLabel>subscribers</StatLabel>
<StatValue value={88} />
<StatChange value={4}>+4 this week</StatChange>
</Stat>
<Stat>
<StatLabel>languages</StatLabel>
<StatValue value={11} />
<StatChange value={0} />
</Stat>
</div>
</Panel>
)
}
// #endregion
// #region edges
/** No previous period (no share of zero exists), a change that is neither good nor
* bad, a label longer than its tile, a count in the billions said short. */
function Edges() {
const fromNothing = shareChange(0, 37)
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Stat>
<StatLabel>referrals</StatLabel>
<StatValue value={37}>
{fromNothing !== null && <StatChange value={fromNothing} />}
</StatValue>
<StatNote>
{fromNothing === null ? "new this month" : "against last month"}
</StatNote>
</Stat>
<Stat>
<StatLabel>median session</StatLabel>
<StatValue>
4m 12s <StatChange value={0.12} good="none" />
</StatValue>
<StatNote>neither good nor bad</StatNote>
</Stat>
<Stat>
<StatLabel>
accounts that signed up through a partner and paid within a week
</StatLabel>
<StatValue value={0}>
<StatChange value={0} />
</StatValue>
<StatNote>no change</StatNote>
</Stat>
<Stat>
<StatLabel>events stored</StatLabel>
<StatValue value={1284903117}>
<StatChange value={0.021} />
</StatValue>
<StatNote>said short, the exact count on hover</StatNote>
</Stat>
</div>
)
}
// #endregion
export default function StatDemo() {
return (
<div className="space-y-8">
<Sample
name="visits"
glance
title="one number and its trend"
with="series visits"
>
<Visits />
</Sample>
<Sample
name="row"
title="a row of tiles"
description="the change's tone says whether the move is good: errors and latency pass good down, so their falls are green and their rises red."
with="series row"
>
<Row />
</Sample>
<Sample
name="panel"
title="several in one panel"
description="inside a frame a stat draws no tile; the change can stand on a line of its own, in a share or in your words."
with="panel"
>
<Overview />
</Sample>
<Sample
name="edges"
title="new, neutral, zero, long"
description="from zero no share exists, so the change is left out and the note says new; good none is never toned; a zero change is said and untoned; a long label wraps; a count in the billions is said short, the exact count on hover."
with="edges"
>
<Edges />
</Sample>
<Sample
name="materials"
title="plain and glass"
description="alone it is a panel of either material; its change lettered on glass catches the rim's light."
with="series visits"
>
<div className="grid items-start gap-4 lg:grid-cols-2">
<Visits />
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
<Visits />
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { compact, withCommas } from "@adriangalilea/utils/format"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
MaterialProvider,
type Scheme,
TONE_WORDS,
type Tone,
useMaterial,
} from "@/registry/base-nova/ui/material"
import { glassWords } from "@/registry/base-nova/ui/material-glass"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"
// ONE NUMBER THAT MATTERS, as a dashboard's tile draws it: what it is, the number big,
// how it moved against the period before, a line of context, and its trend.
//
// <Stat>
// <StatLabel>visits</StatLabel>
// <StatValue>
// 48,210 <StatChange value={0.124} />
// </StatValue>
// <StatNote>last 30 days</StatNote>
// <StatChart><Sparkline values={daily} label="visits by day" /></StatChart>
// </Stat>
//
// A description list (`dl`): the label is its `dt`, the value, the note and the chart
// its `dd`s, so a screen reader hears the number with its name. Each part is optional
// and in the order written; the change sits where it is put, on the value's baseline
// inside `StatValue` or on a line of its own.
//
// THE CHANGE is a signed number (`value`), said by default as a signed share (0.124
// is "+12.4%"), or in the caller's words (children: "+3 seats", "−0.4 pt"). The sign
// says the direction to every reader; the tone says whether that direction is good:
// up is good by default, `good="down"` for what should fall (errors, latency, churn),
// `good="none"` for a change that is neither, and no change is never toned. A screen
// reader hears "better" or "worse" after it, since the tone says nothing to one. Work the share out with
// `shareChange(previous, current)`, null when there was nothing before to compare to
// (no share of zero exists; say "new" in a note instead).
//
// A COUNT is given as `value` and said short, the way a tile is read at a glance
// (1,284,903,117 is "1.3B", ts-utils `compact`), with the exact count on hover and to a
// screen reader. A figure that is no plain count (a duration, a sum of money) is the
// caller's words, as children.
//
// A TILE: alone, a panel round it (plain, or glass with `material="glass"`); inside a
// frame already drawn (a panel holding a row of stats, a tabs box) it draws none, and
// `frame` decides when given. The value is set large in the words' voice with tabular
// figures, so a row of tiles lines up its digits. A figure is never broken across
// lines: one wider than its tile is set as large as the tile holds it whole, measured
// from the element.
/** A `dl`'s attributes; no `title`, which on a panel is its heading. */
export type StatProps = Omit<React.HTMLAttributes<HTMLElement>, "title"> & {
/** A panel round it. Default drawn, except inside a frame already drawn. */
frame?: boolean
material?: Material
/** The colour scheme its panel stands in (material): default the nearest
* provider's. */
scheme?: Scheme
ref?: React.Ref<HTMLDListElement>
}
const STAT = "flex min-w-0 flex-col gap-1"
export function Stat({
frame,
material,
scheme,
className,
children,
ref,
...props
}: StatProps) {
const m = useMaterial(material)
const inFrame = useInFrame()
const body = <MaterialProvider material={m}>{children}</MaterialProvider>
if (frame ?? !inFrame)
return (
<Panel
render={<dl />}
// The panel's element is the dl it renders.
ref={ref as React.Ref<HTMLElement>}
data-slot="stat"
material={m}
scheme={scheme}
className={cn(STAT, className)}
{...props}
>
{body}
</Panel>
)
return (
<dl
ref={ref}
data-slot="stat"
data-material={m}
className={cn("m-0", STAT, className)}
{...props}
>
{body}
</dl>
)
}
/** What the number is. */
export function StatLabel({ className, ...props }: React.ComponentProps<"dt">) {
return (
<dt
data-slot="stat-label"
className={cn("text-[13px] text-current/60 leading-5", className)}
{...props}
/>
)
}
/** Sets the figure as large as its tile holds it whole: its own size (32 px) when it
* fits, else that size times the share of it the tile has room for. Read from the
* element, never guessed from a count of characters: the figure is any node, in
* whatever font the consumer's theme names. */
function fitFigure(element: HTMLElement) {
element.style.removeProperty("--ag-stat-size")
if (element.scrollWidth <= element.clientWidth) return
const full = Number.parseFloat(getComputedStyle(element).fontSize)
const fits = (full * element.clientWidth) / element.scrollWidth
element.style.setProperty("--ag-stat-size", `${Math.floor(fits * 10) / 10}px`)
}
/** The number, large, with tabular figures; a `StatChange` inside it sits on its
* baseline after it, or wraps under it when there is no room. A figure is never
* broken: one too wide for its tile is set smaller until it fits whole. */
export function StatValue({
value,
className,
children,
ref,
...props
}: React.ComponentProps<"dd"> & {
/** A count, said short ("1.3B") with the exact count on hover and to a screen
* reader; children follow it. */
value?: number
}) {
const [element, setElement] = React.useState<HTMLElement | null>(null)
const here = React.useCallback(
(node: HTMLElement | null) => {
setElement(node)
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref],
)
// After every render (the figure may have changed), and whenever the tile's width
// does. Setting the size changes the height only, which settles in one pass.
React.useLayoutEffect(() => {
if (element) fitFigure(element)
})
React.useEffect(() => {
if (!element) return
const watch = new ResizeObserver(() => fitFigure(element))
watch.observe(element)
return () => watch.disconnect()
}, [element])
return (
<dd
ref={here}
data-slot="stat-value"
className={cn(
"m-0 flex min-w-0 flex-wrap items-baseline gap-x-2 whitespace-nowrap font-semibold text-[length:var(--ag-stat-size,2rem)] tabular-nums leading-10 tracking-tight",
className,
)}
{...props}
>
{value !== undefined && <Count value={value} />}
{children}
</dd>
)
}
/** A count said short; when short is not the whole count, the exact one is the title
* and what a screen reader hears. */
function Count({ value }: { value: number }) {
const short = compact(value)
const exact = withCommas(value)
if (short === exact) return <span data-slot="stat-count">{short}</span>
return (
<span data-slot="stat-count" title={exact}>
<span aria-hidden>{short}</span>
<span className="sr-only">{exact}</span>
</span>
)
}
/** A line of context under the number: the period, what it is compared with. */
export function StatNote({ className, ...props }: React.ComponentProps<"dd">) {
return (
<dd
data-slot="stat-note"
className={cn("m-0 text-current/55 text-xs leading-5", className)}
{...props}
/>
)
}
/** Where its trend is drawn (a sparkline), 40 px tall and as wide as the stat; what
* is inside fills it. */
export function StatChart({ className, ...props }: React.ComponentProps<"dd">) {
return (
<dd
data-slot="stat-chart"
className={cn("m-0 mt-2 h-10 *:size-full", className)}
{...props}
/>
)
}
/** Which way is good: `up` (visits, revenue), `down` (errors, latency, churn), or
* `none` (a change that is neither). */
export type StatGood = "up" | "down" | "none"
const share = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
})
/** A direction's sign: the typographic minus, as wide as the plus in tabular
* figures. */
const SIGN = { up: "+", down: "−", flat: "" } as const
/** Whether a change is good: the tokens' tones it is drawn in. */
type Verdict = Extract<Tone, "ok" | "bad">
/** What a screen reader hears after a toned change. */
const VERDICT: Record<Verdict, string> = {
ok: "better",
bad: "worse",
}
export type StatChangeProps = Omit<React.ComponentProps<"span">, "children"> & {
/** The change, signed: by default a share (0.124 is +12.4%). */
value: number
/** Which way is good. Default `up`. */
good?: StatGood
/** The change in your own words ("+3 seats"), in place of the share. */
children?: React.ReactNode
material?: Material
}
/** How the number moved: an arrow, the change, and the tone of whether that is good. */
export function StatChange({
value,
good = "up",
material,
className,
children,
...props
}: StatChangeProps) {
if (!Number.isFinite(value))
throw new Error(`StatChange: ${value} is no change`)
const m = useMaterial(material)
const direction = value > 0 ? "up" : value < 0 ? "down" : "flat"
const tone: Verdict | null =
direction === "flat" || good === "none"
? null
: direction === good
? "ok"
: "bad"
return (
<span
data-slot="stat-change"
data-direction={direction}
data-tone={tone ?? undefined}
className={cn(
"inline-flex items-baseline font-mono font-normal text-[13px] tabular-nums tracking-normal",
tone ? TONE_WORDS[tone] : "text-current/55",
tone && m === "glass" && glassWords,
className,
)}
{...props}
>
{children ?? `${SIGN[direction]}${share.format(Math.abs(value))}`}
{tone && <span className="sr-only"> ({VERDICT[tone]})</span>}
</span>
)
}
/** The change from `previous` to `current` as a share of `previous` (40 to 50 is
* 0.25); null when `previous` is 0 and `current` is not, which no share describes. */
export function shareChange(previous: number, current: number): number | null {
if (!(Number.isFinite(previous) && Number.isFinite(current)))
throw new Error(`shareChange: ${previous} to ${current} is no change`)
if (previous === 0) return current === 0 ? 0 : null
return (current - previous) / Math.abs(previous)
}