← ui

status

ui

how something stands in a word, a dot in its tone before it: live, failing, idle

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

Your 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). pulse for 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 in sr-only. Renders in a server component.
Updated 2026-10-12 · release notes

also called status dot, status indicator, health

shelf data & status

used by deployment

the demo above, verbatim

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

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