Default
Overview
The Indicator is the smallest piece of UI in the system — a 6 px coloured dot that signals state. It exists for the moments where a word is too much and a number is too specific: there’s something new here, this is online, this stage is complete. It does one job, has one shape, and gets out of the way.
Smallest does the most
One dot can change the meaning of an entire screen. A neutral dot on a row label says “there’s new content here” without an unread counter, a banner, or a toast. Reach for it when you want presence without noise — and pair it with text whenever the meaning is non-obvious.
Tones
Seven tones. The colour mapping mirrors Badge so a status dot reads consistently whether it appears alone or inside a pill.
- Neutral
tone="neutral"New / unread / default attention. Charcoal on light, inverse on dark.
- Success
tone="success"Online, complete, healthy. Pair with text — operators don’t read green by feel.
- Warning
tone="warning"Needs review or about to expire. Slow signal, not urgent.
- Danger
tone="danger"Failed, offline, blocking. Reserve it — every red dot earns a reason.
- Info
tone="info"Update available, change recorded. Quieter than neutral.
- Discovery
tone="discovery"AI / experimental / model-related state.
Sizes
Three sizes. md at 6 px is the default and matches the Figma. Reach for sm only when the dot sits inside dense table metadata; reach for lg only when it stands alone with no text companion.
size="sm"4 pxInline beside dense, list-row metadata.
size="md"6 pxDefault — beside section headings, in nav.
size="lg"8 pxStandalone signals at the edge of a card.
Filled vs outline
Two variants. Filled is the loud one — “there is something here.” Outline is the quiet one — “this slot is accounted for, but at rest.” They’re designed as a pair: read/unread, done/pending, present/absent.
- Stripe webhook signature mismatch
- Adyen webhook delivered
- Daily settlement reconciled
- Risk model retraining queued
- Connect repo
- Add manifest
- Pick a model
- Invite reviewers
- Promote to demo-ready
Pulse
Set pulse to mark a livestate — recording, streaming, transcribing. It’s the loudest thing the indicator can do, so use it sparingly: a pulse on every dot is just visual chatter.
Reduced motion
The pulse uses Tailwind’s motion-safe:animate-ping — when a user has prefers-reduced-motion on, the dot stops pulsing and stays solid. The state remains legible without the animation.
Pairings
Three places it shows up: pinned to an icon’s corner, inline beside a nav label, and inside a tab to telegraph the environment’s health. The component itself is just a dot — positioning is the parent’s job.
On dark surfaces
On a charcoal panel, the neutral charcoal dot disappears. Use tone="inverse" for neutral on dark; saturated tones (success / danger / info) work on either surface unchanged.
tone=“inverse” for neutral, or any saturated tone for status.Anatomy
Two parts. The dot is everything; the optional pulse ring sits behind it.
Dot
6 px circle in the tone’s saturated foreground colour (e.g.
#005734for success). The same hue used by Badge's text foreground — so dot and pill read as the same state.Pulse ring
Optional. When
pulseis on, a 6 px ring of the same hue expands and fades behind the dot using Tailwind’sanimate-ping. Suppressed under reduced-motion.
Accessibility
- Standalone dots need a label.If the indicator isn’t paired with text, pass an
aria-label. The component then renders withrole="status"so a screen reader can announce the state. - When paired with text, hide the dot. Omit
aria-label— the component falls back toaria-hidden="true"so the same state isn’t announced twice. - Colour is never the only signal.Don’t mark “done” with green only — pair the colour with position (filled vs outline) or text. Operators with red-green colour vision deficiency need to read the meaning, not the hue.
- Pulse respects user preferences.
prefers-reduced-motion: reducestops the pulse — the static dot still communicates the state, so nothing is lost.
Code
tsx
import { Indicator } from "@flatpay-dk/ui";
// Inline beside a label — paired with text, no aria-label needed
<span className="flex items-center gap-2">
<Indicator tone="success" />
Demo-ready
</span>
// Standalone — must announce its meaning
<Indicator tone="danger" aria-label="Webhook failed" />
// Pinned to an icon's corner with pulse for live state
<button className="relative" aria-label="Inbox, 3 unread">
<MailIcon />
<span className="absolute right-1 top-1">
<Indicator tone="danger" pulse />
</span>
</button>
// Read / unread — same hue, different variant
<Indicator tone="info" variant={message.read ? "outline" : "filled"} />Best practices
Do
Pair the dot with text whenever the meaning isn't obvious from context. Operators don't read green by feel.
Don't
Don't sprinkle colours across a list to decorate it. The dot earns its hue by communicating state.
Do
Use pulse only for live, momentary states — recording, streaming, transcribing. Stop it when the activity stops.
Don't
Don't pulse decoratively. A pulse that runs forever on every page is the kind of motion that gets turned off in settings.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "neutral" | "success" | "warning" | "danger" | "info" | "discovery" | "inverse" | "neutral" | The state the dot communicates. Hue mapping mirrors Badge. |
| variant | "filled" | "outline" | "filled" | Filled = present / unread / done. Outline = pending / read / inactive. Use as a pair. |
| size | "sm" | "md" | "lg" | "md" | 4 / 6 / 8 px. Default is 6 px, matching the Figma. |
| pulse | boolean | false | When true, paints a softly expanding ring beneath the dot. Reserved for live, momentary states. |
| aria-label | string | — | Required when standalone. Omit when paired with descriptive text — the dot falls back to aria-hidden. |
| className | string | — | Pass-through class for positioning (e.g. absolute placement on an icon). |