Status
A status dot with two axes — presence (online, away, busy, offline; shape-coded from md up as disc, moon, bar and hollow ring) and intent (none, error, warning, success, info; with a glyph from md up). Named for assistive technology by a human word, optionally shown as text, with pulse, a backdrop ring for overlapping an avatar, six sizes from 6 to 20 px and a rounded scale from circle to square.
pnpm add @tessinaui/uiWhen to use
- The state of a thing or the presence of a person, shown as a small dot. Meaning comes from shape and colour; there is no count and no text unless you ask for it.
presencefor a person,intentfor a thing. Presence is where someone is (online, away, busy, offline) and is shape-coded because the four are read side by side in a list. Intent is the state of a deployment, a sync or a connection, one at a time, with a glyph to back the colour. Never usepresence="busy"to mean "failed" orintent="success"to mean "online". When both are given, presence wins and intent is ignored.- Beside text that names the state, or with
showLabel. A dot never carries its meaning by colour alone, and belowmdit has no shape to help. - The same state, the same word and colour everywhere. People learn a dot once; a second colour for the same state undoes that.
- A short, specific label when the generic word is wrong. One word in
sentence case ("Sync failed", "Recording"); the defaults are English, so
localise through
label.
When not to use
- To show a count or a "new" marker. Use Badge, which says how many, not which state.
- On a person's picture. Use Avatar's
presence. It positions the dot on the rim, rings it in the right backdrop and composes the name ("Jane Doe, Online"). Do not absolutely-position a standalone Status over an avatar. - For something people press. A status dot is not a button or a toggle, and nobody will guess that it is clickable. Use Button or ToggleButton, and put the dot inside.
- For a state nobody needs to act on or notice. Plain text says it with less noise, and a screen full of dots stops anything standing out.
- To explain or warn at length. Use Alert; a dot names a state, it does not describe it.
- To announce a change. Status is not a live region. When a state flips and people need to hear it, announce it from the surrounding component.
Installation
pnpm add @tessinaui/uiUsage
import { Status } from "@tessinaui/ui";<Status presence="online" />
<Status presence="away" showLabel />
<Status intent="error" size="lg" label="Sync failed" pulse />Labels are one word in sentence case, which is what the defaults are. Be
specific only when the generic word is wrong for the context ("Sync failed",
"Recording"), and never use the enum token as copy. Localise through label.
Examples
Default
A presence dot. With nothing set it is online, and it announces "Online" —
the human word, never the enum token.
Presence
presence says where someone is. The four states are shape-coded as well as
colour-coded — online is a filled disc, away a moon, busy a bar,
offline a hollow ring — so from md up they read apart with the colour
removed. Below md there is no room for the moon or the bar, so a small dot
needs a visible label (showLabel) or text beside it that names the state.
Intents
intent says what state something is in: a plain filled dot with a glyph from
md up. none is the neutral dot, painted in --muted-foreground so it still
clears 3:1 against the page.
Sizes
Six diameters — 2xs 6 px, xs 8 px, sm 10 px, md 12 px, lg 16 px,
xl 20 px. Glyphs, presence and intent alike, appear from md up, where they
can be read, and scale with the dot. Below md the dot carries its colour
alone; only offline keeps its shape, the hollow ring.
Rounded
The dot's radius derives from rounded: full (the default) is a circle,
none a square marker, and sm to xl are rounded squares. The presence
glyphs are drawn for the circle; square markers read best with intents.
With visible label
showLabel renders the name as text beside the dot. The visible text is the
accessible name, so the root drops role="img" and aria-label — nothing is
read twice. label replaces the default word when the generic one is not
specific enough.
Pulse
pulse is Tailwind's own animate-pulse — the same two-second opacity curve
every reference implementation hand-writes — and it is switched off under
prefers-reduced-motion. Reserve it for a state that needs attention now.
On a coloured surface
Nothing here sets tone. The <Surface> publishes its fill as
--surface-fill, and that is the backdrop the ring and the offline hollow
are painted in — they follow the primary fill instead of assuming the page is
white. The glyphs do NOT follow it: the moon and the bar are drawn in their
fill's own locked ink (--on-warning, --on-error), so they stay legible on
every plate. The label inherits the surface ink.
Decorative
label={null} makes the dot decorative — no role, hidden from assistive
technology. Use it only when the meaning is already in the text of what the dot
sits beside, as in these rows, which each read "Jane Doe … Online" on their
own. An Avatar does this for you: its dot is decorative and the avatar carries
"Jane Doe, Online".
Localised label
The default names are English words. Pass label to localise — it is the
accessible name either way, and the visible text with showLabel.
Icon visibility
Intents draw their glyph from md up. showIcon={false} suppresses it; it
cannot force a glyph below md, where an 8 px mark would not be legible.
Skeleton
StatusSkeleton reads its diameter from the same STATUS_SIZE map as the real
dot and announces "Loading" as a live region.
RTL
Status has no dir prop. It follows the direction it sits in, so with
showLabel the dot moves to the right of the text. The presence glyphs do not
flip. Pass label, since the default names are English.
API Reference
Status
Extends the native <span> attributes (children excluded).
| Prop | Type | Default | Description |
|---|---|---|---|
presence | "online" | "away" | "busy" | "offline" | — | Where someone is. Shape-coded from md up. Wins over intent when both are given. With neither set the dot is online |
intent | "none" | "error" | "warning" | "success" | "info" | — | What state something is in. A filled dot with a glyph from md up. Ignored when presence is set |
variant (deprecated) | "online" | "away" | "busy" | "offline" | "neutral" | "error" | "warning" | "success" | "info" | — | Since 2026-08-23 — the old single enum, mapped onto presence / intent (see Migrating from variant). An explicit presence or intent wins over it. Removed at the next major |
size | "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Dot diameter: 6 / 8 / 10 / 12 / 16 / 20 px |
label | string | null | the English word for the state | Accessible name — and the visible text with showLabel. Pass a translation to localise. null makes the dot decorative: no role, aria-hidden |
showLabel | boolean | false | Render the label as visible text beside the dot. The text is then the name; no role or aria-label is set |
pulse | boolean | false | Pulse the dot (animate-pulse, off under prefers-reduced-motion) |
ring | boolean | false | Draw a cut-out ring in the backdrop colour — the nearest <Surface>'s fill, or the page — for a dot that overlaps something. Width steps with size |
showIcon | boolean | true for intents | Show the intent glyph inside the dot. Only suppresses: below md there is never a glyph |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner treatment of the dot: full is a circle, none a square marker, the steps between are rounded squares |
tone | "default" | "on-color" | inherited from <Surface> | On-color, a visible label takes currentColor — the surface ink — instead of --foreground |
Default labels — Online, Away, Busy, Offline for presence;
Status, Error, Warning, Success, Info for intents.
Data attributes — the root carries data-slot="status", either
data-presence or data-intent (whichever axis is in effect), data-size
and data-tone; the inner dot carries data-slot="status-dot".
Size scale
| Size | Diameter | Backdrop ring | offline outline | Glyph (presence + intent) |
|---|---|---|---|---|
2xs | 6 px | 1 px | 1 px | — (colour alone) |
xs | 8 px | 1 px | 1 px | — |
sm | 10 px | 1 px | 1 px | — |
md | 12 px | 1.5 px | 1.25 px | 8 px |
lg | 16 px | 2 px | 1.75 px | 10 px |
xl | 20 px | 2 px | 2 px | 12 px |
The presence glyphs are the library's own icons, a moon and a bar, drawn at the
bold weight in the fill's locked ink, exactly as the intent glyphs are: the
moon is --on-warning (black in both themes, 9.67:1 / 12.36:1) and the bar
--on-error (4.77:1 / 5.44:1). They were briefly painted in the backdrop colour
instead, which put a white moon on amber at ~1.9:1 in light — a glyph must
contrast with its own dot, not with the page behind it. Presence and intent
share one size and one weight, so a moon and a check sitting in the same row
read as one icon set. Both appear from md up: below 12 px a glyph is a smudge,
not a shape.
online is a plain disc and offline a hollow ring at every size. The ring is
not an icon — it is the dot's own boundary, drawn as a border so it follows the
radius cascade (a squared-off dot gets a squared-off ring). Its width comes
from the same stroke constant, mapped onto the dot's box instead of a glyph's,
so the four states carry one outline weight; it is floored at 1 px, below which
a border fades to a subpixel hairline and stops carrying the state.
StatusSkeleton
Extends the native <div> attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Matches the diameter of the dot it stands in for |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Matches the radius of the dot it stands in for |
Renders role="status" with visually hidden "Loading" text, written 150ms after
mount so the announcement fires on insertion (a live region announces its
content; an aria-label there would announce nothing, and aria-busy would
silence it).
Helpers
| Export | Type | Description |
|---|---|---|
STATUS_SIZE | Record<StatusSize, string> | The diameter utility per size (size-1.5 … size-5). The skeleton and Avatar read it |
STATUS_PX | Record<StatusSize, number> | The same diameters as numbers (6 · 8 · 10 · 12 · 16 · 20), for consumers that place the dot with calc() — Avatar does |
STATUS_RING | Record<StatusSize, string> | The ring width utility per size (ring-1 … ring-2) |
resolveStatusAxes(input) | ({ presence?, intent?, variant? }) => { presence?, intent? } | Resolves the two axes from props: presence first, then intent, then the deprecated variant alias. Shared with Avatar and Chip |
statusLabelFor(state) | (state: StatusPresence | StatusIntent) => string | The default accessible name for a state — for a component that composes it into its own name, the way Avatar builds "Jane Doe, Online" |
Types: StatusPresence, StatusIntent, StatusSize, StatusRounded,
StatusTone, StatusProps, StatusSkeletonProps and the deprecated
StatusVariant.
Accessibility
No APG pattern applies — a status dot is an image, not a widget. It is never focusable and has no keyboard interaction; if a state needs an action, put the dot inside the control and let the control carry the name.
What is announced
VoiceOver + Safari passed 2026-09-14 on the first row: <Status presence="online" />
read "Online image". The other rows are expected from the markup, not a
recorded run.
| Markup | VoiceOver |
|---|---|
<Status presence="online" /> | "Online, image" — role="img" named by label |
<Status intent="error" label="Sync failed" /> | "Sync failed, image" |
<Status presence="away" showLabel /> | "Away" — plain text; no role, no aria-label, so it is not read twice |
<Status presence="busy" label={null} /> | Nothing — aria-hidden, no role |
<Avatar name="Jane Doe" presence="online" /> | "Jane Doe, Online, image" — the avatar composes the name; its dot is decorative |
<StatusSkeleton /> | "Loading" on insertion — role="status" with real (visually hidden) text |
The intent and presence glyphs are aria-hidden; the name comes from the
word, never from the icon.
Why shapes
From md up, colour is never the only signal (WCAG 1.4.1). The amber away
dot is 2.17:1 against white — under the 3:1 a non-text indicator needs — and to
a colour-blind user the green, amber and red fills collapse into one; dark mode
shifts the tints again. So presence is drawn: a filled disc, a moon, a bar, a
hollow ring. The glyphs are painted in the fill's own locked ink, so the shape
holds its contrast on the dot itself; the ring and the offline hollow take the
backdrop colour (--surface-fill, or the page), which is what seats the dot on
whatever it overlaps. Below md there is no room for the moon or the bar and
only offline keeps its shape, so a small dot must sit beside text that names
its state, or carry showLabel.
Contrast
- The neutral dot (
intent="none") is--muted-foreground, not--secondary: neutral-200 on white is 1.2:1 and invisible, where neutral-500 clears the 3:1 boundary contrast a non-text indicator needs (WCAG 1.4.11). offlinepaints the backdrop and carries its colour in a--outline-borderring, so the hollow stays hollow on the page and on a coloured surface.
When to use label={null}
Only when the dot's meaning is already in the accessible name of the thing it
decorates. A list row whose text says "Online", a chip whose label carries the
state, an Avatar (which composes "Jane Doe, Online" itself). A lone decorative
dot tells a screen-reader user nothing — if there is no text nearby that says
what it means, keep the default name or set showLabel.
Motion — pulse is disabled under prefers-reduced-motion (WCAG 2.3.3).
Migrating from variant
variant was one enum that mixed where someone is with what state something
is in. It still compiles for one minor and maps as below; move each call site to
the axis it meant.
variant | Now |
|---|---|
"online" | presence="online" |
"away" | presence="away" |
"busy" | presence="busy" |
"offline" | presence="offline" |
"neutral" | intent="none" — the grey dot. If it meant "not here", use presence="offline" (the hollow ring) instead |
"error" | intent="error" |
"warning" | intent="warning" |
"success" | intent="success" |
"info" | intent="info" |
The size scale also moved: it now starts at 2xs (6 px) and tops out at xl
(20 px). The old xs / sm / md / lg (8 / 12 / 16 / 20 px) are today's
xs / md / lg / xl; the old 24 px xl has no equivalent.