Status
A status dot with two axes — presence (online, away, busy, offline; shape-coded 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.
Playground
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 />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 they read apart with the colour removed.
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. Presence glyphs scale with the dot; an intent glyph only appears
from md, where it can be read.
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.
API Reference
Status
Extends the native <span> attributes (children excluded).
| Prop | Type | Default | Description |
|---|---|---|---|
presence | "online" | "away" | "busy" | "offline" | — | Where someone is. Shape-coded. 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 — lucide Moon and Minus,
stroked like every other icon, never filled — drawn 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.52: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 stroke weight (2.5), 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 stroked 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 aria-busy and visually hidden "Loading" text, so
the announcement fires on insertion (a live region announces its content; an
aria-label there would announce nothing).
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
| 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
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.
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).
When to use which
presence or intent? Presence is about a person: where they are
(online, away, busy, offline), and it is shape-coded because the four are
read side by side in a list. Intent is about a thing: the state of a
deployment, a sync, a connection (error, warning, success, info), one at a
time, with a glyph to back the colour. Do not use presence="busy" to mean
"failed" or intent="success" to mean "online". When both are given, presence
wins and intent is ignored.
Status, Badge or Avatar's own dot?
- Status — the state of a thing or the presence of a person. Meaning comes from shape and colour; there is no count and no text unless you ask for it.
- Badge — a notification overlay: a count, a short label, "new". It says how many, not which state.
- Avatar's
presence— when the dot belongs to a person's picture, use the avatar's prop. 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.
Labels are one word in sentence case — the defaults — unless the generic
word is wrong for the context; then be specific ("Sync failed", "Recording").
Localise through label; the defaults are English. Never use the enum token as
copy.
Non-goals — a status dot is not a button, a toggle or a live region. It does not announce changes on its own; when a state flips and the user needs to hear it, announce that from the surrounding component.
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.