how something stands in a word, a dot in its tone before it: live, failing, idle
npx shadcn add @ag/statusAlready installed? Preview an update
npx shadcn add @ag/status --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
Status, how something stands in your word with a dot in its tone before it (tone:ok,warn,bad,info,neutral, the tokens' tones).pulsefor work in progress, still with reduced motion. The dot is 0.7em of its words and the status sits on their baseline, so it lines up in a table, a readout, a sentence or a heading. A dot alone: put its word insr-only. Renders in a server component.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
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 { Status } from "@/registry/base-nova/ui/status"
// #region states
/** No pulse here: a page that holds still never pulses (the build below does, while
* it builds). */
function States() {
return (
<div className="flex flex-col gap-2 text-sm">
<Status tone="ok">live</Status>
<Status tone="warn">degraded</Status>
<Status tone="bad">failing</Status>
<Status tone="info">scheduled</Status>
<Status>paused</Status>
<Status>idle</Status>
</div>
)
}
// #endregion
// #region services
/** Beside names, as a readout's values: the dot follows the value's smaller size. */
function Services() {
return (
<Readout className="w-80">
<ReadoutRow label="api">
<Status tone="ok">live</Status>
</ReadoutRow>
<ReadoutRow label="worker, eu-west-1">
<Status tone="warn">degraded</Status>
</ReadoutRow>
<ReadoutRow label="search index">
<Status tone="bad">failing since the last schema change</Status>
</ReadoutRow>
<ReadoutRow label="mail">
<Status>paused</Status>
</ReadoutRow>
</Readout>
)
}
// #endregion
// #region build
/** Pulsing only while the work runs: the build ends and the dot stops. */
function Build() {
const [state, setState] = React.useState<"idle" | "building" | "live">("idle")
React.useEffect(() => {
if (state !== "building") return
const done = window.setTimeout(() => setState("live"), 4000)
return () => window.clearTimeout(done)
}, [state])
return (
<div className="flex items-center gap-4 text-sm">
<Button
variant="outline"
size="sm"
disabled={state === "building"}
onClick={() => setState("building")}
>
deploy
</Button>
{state === "idle" && <Status>idle</Status>}
{state === "building" && (
<Status tone="warn" pulse>
building
</Status>
)}
{state === "live" && <Status tone="ok">live</Status>}
</div>
)
}
// #endregion
// #region dots
/** Dots alone, a row of health checks: each carries its word for a screen reader,
* and a title for the hand. */
const CHECKS = [
{ name: "mon", tone: "ok" },
{ name: "tue", tone: "ok" },
{ name: "wed", tone: "warn" },
{ name: "thu", tone: "ok" },
{ name: "fri", tone: "bad" },
{ name: "sat", tone: "ok" },
{ name: "sun", tone: "neutral" },
] as const
const WORDS = {
ok: "healthy",
warn: "slow",
bad: "down",
neutral: "not checked",
} as const
function Dots() {
return (
<ul className="m-0 flex list-none gap-3 p-0 text-sm">
{CHECKS.map((c) => (
<li key={c.name} title={`${c.name}: ${WORDS[c.tone]}`}>
<Status tone={c.tone}>
<span className="sr-only">
{c.name}: {WORDS[c.tone]}
</span>
</Status>
</li>
))}
</ul>
)
}
// #endregion
export default function StatusDemo() {
return (
<div className="space-y-8">
<Sample name="states" glance title="the states" with="states">
<States />
</Sample>
<Sample
name="pulse"
title="a pulse while it works"
description="the pulse is the work in progress: when the build ends the dot holds still. with reduced motion it never moves."
with="build"
>
<Build />
</Sample>
<Sample
name="services"
title="beside names"
description="a long word stays on one line; the dot is sized from the words beside it, here a readout's smaller mono."
with="services"
>
<Services />
</Sample>
<Sample
name="sizes"
title="in proportion to its words"
description="the dot is 0.7em: in a caption, in body text, in a heading, and on the text's baseline in a sentence."
>
<div className="space-y-3">
<p className="text-xs">
<Status tone="ok">caption, 12 px</Status>
</p>
<p className="text-sm">
the api has been <Status tone="ok">live</Status> since tuesday.
</p>
<h3 className="font-medium text-2xl">
<Status tone="bad">checkout is down</Status>
</h3>
</div>
</Sample>
<Sample name="dots" title="dots alone" with="dots">
<Dots />
</Sample>
<Sample
name="materials"
title="plain and on glass"
description="it draws no surface: it reads on whatever holds it."
with="states"
>
<div className="grid gap-4 sm:grid-cols-2">
<Panel title="services">
<States />
</Panel>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
<Panel title="services">
<States />
</Panel>
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
import type * as React from "react"
import { cn } from "@/lib/utils"
// HOW SOMETHING STANDS, in a word with a dot in its tone before it: live, paused,
// failing, building, idle. The word is the consumer's; the tone is the tokens'
// (`--tone-ok`, `--tone-warn`, `--tone-bad`, `--tone-info`), never a colour of its own,
// and `neutral` is the text's colour a long way back, for what is neither good nor
// bad (idle, queued, paused).
//
// <Status tone="ok">live</Status>
// <Status tone="warn" pulse>building</Status>
// <Status tone="ok"><span className="sr-only">healthy</span></Status> a dot alone
//
// `pulse` is for work in progress, and the dot only pulses while it is: whoever sets it
// clears it when the work ends (nothing here animates forever). With reduced motion it
// holds still. The dot is decoration to a screen reader, which hears the word; a dot
// alone carries its word in `sr-only`.
//
// The dot is sized in em (0.7 of the words beside it), so a status in a table's 13 px
// and one in a heading are each in proportion. The status sits on its words' baseline
// in running text, the dot centred on their line. It draws in the voice of where it
// stands: weight and colour are the words' around it, or `className`'s.
//
// No "use client": it renders in a server component as it is.
export type StatusTone = "ok" | "warn" | "bad" | "info" | "neutral"
const DOT: Record<StatusTone, string> = {
ok: "bg-tone-ok",
warn: "bg-tone-warn",
bad: "bg-tone-bad",
info: "bg-tone-info",
neutral: "bg-current/30",
}
export type StatusProps = React.ComponentProps<"span"> & {
/** The dot's tone. Default `neutral`. */
tone?: StatusTone
/** The dot pulses: work in progress, cleared when it ends. */
pulse?: boolean
}
export function Status({
tone = "neutral",
pulse = false,
className,
children,
...props
}: StatusProps) {
return (
<span
data-slot="status"
data-tone={tone}
data-pulse={pulse || undefined}
className={cn(
// Its baseline is the words' (the dot opts out of it), so beside text it
// lines up as a word does.
"inline-flex min-w-0 items-baseline gap-2 whitespace-nowrap",
className,
)}
{...props}
>
<span
aria-hidden
data-slot="status-dot"
className={cn(
"size-[0.7em] shrink-0 self-center rounded-full",
DOT[tone],
pulse && "motion-safe:animate-pulse",
)}
/>
{children}
</span>
)
}