← ui

stat

ui

one number that matters as a tile: its name, the figure, how it moved, its trend

npx shadcn add @ag/stat
Already installed? Preview an update
npx shadcn add @ag/stat --dry-run

Your 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, StatNote and StatChart (a 40 px slot a sparkline fills). StatChange takes a signed value, said as a signed share or in your words (children), toned by whether its direction is good (good: up, down for 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 (frame overrides).
Updated 2026-10-12 · release notes

also called kpi, metric, stat card

shelf data & status

uses material, panel

the demo above, verbatim

stat.demo.tsx
"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

stat.tsx
"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)
}