← ui

readout

ui

names and their values at a glance, the values in mono with tabular figures

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

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: ReadoutTone is gone; the tone is Tone from @ag/material, where the toned words' colours (TONE_WORDS) now live once for readout and group-box.
Updated 2026-10-12 · release notes

also called key value, definition list

shelf data & status

uses material

used by components, trace

the demo above, verbatim

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

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