names and their values at a glance, the values in mono with tabular figures
npx shadcn add @ag/readoutAlready installed? Preview an update
npx shadcn add @ag/readout --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Changed:
ReadoutToneis gone; the tone isTonefrom@ag/material, where the toned words' colours (TONE_WORDS) now live once for readout and group-box.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import { Badge } from "@/registry/base-nova/ui/badge"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import { Readout, ReadoutRow } from "@/registry/base-nova/ui/readout"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/registry/base-nova/ui/resizable"
// #region queue
/** A job queue as its service reports it: one worker short, so that row is off. A
* name and its value per row; two numbers share a row only when they are one fact
* (14 of 16 is a share), never two facts under one name. */
function Queue() {
return (
<Readout>
<ReadoutRow label="healthy workers" tone="warn">
14 of 16
</ReadoutRow>
<ReadoutRow label="waiting">212</ReadoutRow>
<ReadoutRow label="in flight">8</ReadoutRow>
<ReadoutRow label="process, average">38 ms</ReadoutRow>
<ReadoutRow label="process, p95">140 ms</ReadoutRow>
<ReadoutRow label="completed">48,210</ReadoutRow>
<ReadoutRow label="errors">0</ReadoutRow>
<ReadoutRow label="last job">4 s ago</ReadoutRow>
</Readout>
)
}
// #endregion
// #region database
/** Values that are more than words: a status badge, a link to what shipped. */
function Database() {
return (
<Readout>
<ReadoutRow label="state">
<Badge variant="success">primary</Badge>
</ReadoutRow>
<ReadoutRow label="size">4.2 GB</ReadoutRow>
<ReadoutRow label="write-ahead log">380 MB</ReadoutRow>
<ReadoutRow label="replica lag" tone="bad">
41 s
</ReadoutRow>
<ReadoutRow label="backups, last 7 days" tone="ok">
7 of 7
</ReadoutRow>
<ReadoutRow label="schema">
<a
href="/updates"
className="underline decoration-current/35 underline-offset-2 hover:decoration-current"
>
2026.10.11
</a>
</ReadoutRow>
</Readout>
)
}
// #endregion
export default function ReadoutDemo() {
return (
<div className="space-y-8">
<Sample name="queue" glance title="a queue at a glance" with="queue">
<div className="w-72">
<Queue />
</div>
</Sample>
<Sample
name="values"
title="tones and values that are nodes"
description="a row is off (bad, warn) or right when that is news (ok); the colour is on the value, and a screen reader hears its word."
with="database"
>
<div className="max-w-sm">
<Database />
</div>
</Sample>
<Sample
name="narrow"
title="stacked when narrow"
description="drag the handle: under 24 characters wide, the readout's own width, each name sits over its value."
with="queue"
>
<div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
<ResizablePanelGroup>
<ResizablePanel defaultSize={0.2} minSize={0.12}>
<div className="p-4">
<Queue />
</div>
</ResizablePanel>
<ResizableHandle withHandle aria-label="the readout's width" />
<ResizablePanel>
<div className="p-4">
<Database />
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</Sample>
<Sample
name="materials"
title="plain and on glass"
description="it draws no surface of its own: it reads on whatever holds it, here a panel of either material."
with="queue"
>
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="queue" className="w-full max-w-sm">
<Queue />
</Panel>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
<Panel title="queue" className="w-full max-w-sm">
<Queue />
</Panel>
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
TONE_WORDS,
type Tone,
useMaterial,
} from "@/registry/base-nova/ui/material"
import { glassWords } from "@/registry/base-nova/ui/material-glass"
// NAMES AND THEIR VALUES, read at a glance: what fills an operations page, a service's
// queue, a database's size, when it last synced. A description list (`dl`, a `dt` and
// a `dd` per row), so a screen reader hears each value with its name.
//
// <Readout>
// <ReadoutRow label="healthy workers" tone="warn">14 of 16</ReadoutRow>
// <ReadoutRow label="last sync">12 s ago</ReadoutRow>
// </Readout>
//
// Two columns: the names, in the words' voice one step back, and the values in one
// column after them, in mono with tabular figures. A value is a measurement (a count,
// a size, a time, a version), the metadata the mono voice exists for, and in mono
// every glyph is one width, so "48,210" and "212" line up their digits down the
// column. A value is any node: a badge, a link, a field. One fact a row, its name
// beside it: two numbers share a row only when they are one fact ("14 of 16", a
// share), never two facts under one name ("waiting · in flight" made the reader work
// out which number was which).
//
// `tone` says a value is off: `warn` (look at it), `bad` (it failed), `ok` (it is
// what it should be, when that is news). The tokens' tones, on the value alone; a
// screen reader hears the tone's word after it, since a colour says nothing to one.
//
// NARROW, STACKED: under 24 characters wide (the readout's own width, a container
// query, so a narrow column of a wide page stacks and a wide one does not), each name
// sits over its value. 24ch is the least a row side by side holds: a name of about
// twelve characters, the gap, a value of about ten; a longer name wraps in its column
// (the names take at most 60% of the width). A higher line stacks a dashboard's 15rem
// column, every group twice as tall as it needs. The readout is the query container,
// so it takes the width of where it is put and never sizes itself by its content
// (inside a `w-fit` box or a flex item with no basis it is as narrow as that allows).
//
// It draws no surface and wears no scheme: it is in `currentColor`, inside whatever
// holds it (a group box, a panel, glass). `material="glass"` (or a glass provider
// round it) is lettering on glass: a toned value catches the rim's light as a field's
// error does.
/** What a screen reader hears after a toned value. */
const TONE_WORD: Record<Tone, string> = {
ok: "ok",
warn: "warning",
bad: "error",
}
/** The names: the words' voice, a step back from the values. */
const NAME = "font-sans text-[13px] text-current/60 leading-5"
/** The values: mono, tabular, at full ink. */
const VALUE = "min-w-0 font-mono text-xs text-current/90 tabular-nums leading-5"
export type ReadoutProps = React.ComponentProps<"dl"> & {
material?: Material
}
export function Readout({ material, className, ...props }: ReadoutProps) {
const m = useMaterial(material)
return (
<dl
data-slot="readout"
data-material={m}
className={cn(
// The names' column as wide as its longest name up to 60% of the readout
// (past that a name wraps), the values in the rest.
"@container/readout m-0 grid min-w-0 grid-cols-[fit-content(60%)_minmax(0,1fr)] gap-y-1 text-[13px]",
className,
)}
{...props}
/>
)
}
export type ReadoutRowProps = Omit<React.ComponentProps<"div">, "children"> & {
/** What the value is. */
label: React.ReactNode
/** The value is off (`warn`, `bad`), or right when that is news (`ok`). */
tone?: Tone
/** Its own `material`, else the readout's provider's: only how a toned value is
* lettered on glass. */
material?: Material
/** The value: words, a number, or any node (a badge, a link, a field). */
children: React.ReactNode
}
export function ReadoutRow({
label,
tone,
material,
className,
children,
...props
}: ReadoutRowProps) {
const m = useMaterial(material)
return (
<div
data-slot="readout-row"
data-tone={tone}
className={cn(
// Stacked by default, a pair a step apart from the next; side by side on the
// readout's two columns (subgrid, so every row's value starts where every
// other's does) from 24ch.
"col-span-2 grid min-w-0 items-baseline @max-[24ch]/readout:not-last:pb-1 @min-[24ch]/readout:grid-cols-subgrid @min-[24ch]/readout:gap-x-4",
className,
)}
{...props}
>
<dt data-slot="readout-label" className={NAME}>
{label}
</dt>
<dd
data-slot="readout-value"
className={cn(
"m-0",
VALUE,
tone && TONE_WORDS[tone],
tone && m === "glass" && glassWords,
)}
>
{children}
{tone && <span className="sr-only"> ({TONE_WORD[tone]})</span>}
</dd>
</div>
)
}