Status badge
Renders a supplied status key as a label, a tone and a shape, so state never rests on colour alone. Each tone has its own icon or compact mark; unknown keys fall back to a neutral label.
cmp_status_badge_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_status_badge_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e
This component needs all 2 files. Download the ZIP
<script lang="ts">
import {
DEFAULT_STATUSES,
TONE_ICONS,
type StatusIcon,
type StatusMap,
type StatusTone
} from './statuses';
interface Props {
/** The status key from your data, such as "operational" or "paid". */
status: string;
/** Map from key to label, tone and icon. Replaces the defaults; spread them in to extend. */
statuses?: StatusMap;
/** Indicator style: a 14 px icon, or a compact 10 px mark. Both keep one shape per tone. */
indicator?: 'icon' | 'dot';
/** Visually hidden text read before the label, such as "Order status:". */
prefix?: string;
/** Pulse the indicator for a live state. Still under prefers-reduced-motion. */
pulse?: boolean;
/** Label for a key the map does not know. Defaults to the key itself. */
fallbackLabel?: string;
/** Extra classes on the badge, for margins or a tighter max-width. */
class?: string;
}
let {
status,
statuses = DEFAULT_STATUSES,
indicator = 'icon',
prefix,
pulse = false,
fallbackLabel,
class: className
}: Props = $props();
/*
* Each tone sets one ink, --_tone, for the label, and one for the mark. The tint and ring
* are mixed from the ink in the style block, so a single token retones the whole badge.
* Complete class names, never interpolated.
*/
const TONES: Record<StatusTone, string> = {
neutral: 'status-badge--neutral [--_tone:var(--_muted)] [--_mark:var(--_muted)]',
info: '[--_tone:var(--_info)] [--_mark:var(--_info)]',
success: '[--_tone:var(--_success)] [--_mark:var(--_success)]',
warning: '[--_tone:var(--_warning)] [--_mark:var(--_warning)]',
danger: '[--_tone:var(--_danger)] [--_mark:var(--_danger)]'
};
const SHAPES = new Set<string>(['dot', 'check', 'clock', 'alert', 'cross']);
const known = $derived(Object.hasOwn(statuses, status) ? statuses[status] : undefined);
const tone = $derived<StatusTone>(
known && Object.hasOwn(TONES, known.tone) ? known.tone : 'neutral'
);
const icon = $derived<StatusIcon>(
known?.icon && SHAPES.has(known.icon) ? known.icon : TONE_ICONS[tone]
);
// The first candidate with visible text wins, so a blank label or key never renders empty.
const label = $derived(
[known?.label, fallbackLabel, status].find((text) => text?.trim()) ?? 'Unknown'
);
// An unmapped key still renders, neutral and labelled; the warning is for the developer.
$effect(() => {
if (!known && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) {
console.warn(`StatusBadge: no entry for status "${status}" in the statuses map.`);
}
});
</script>
<!--
Shapes, not just colours: each icon is a different silhouette, and the compact dot set keeps
the same distinction (disc, half disc, triangle, diamond, ring), so a column of statuses reads
in greyscale. Glyphs that knock a mark out of a filled shape paint it in --_surface.
-->
<span
class={[
'status-badge inline-flex h-6 max-w-full min-w-0 items-center gap-1 rounded-md bg-(--_tint) pe-2 text-[0.8125rem] leading-6 font-medium tracking-normal whitespace-nowrap text-(--_tone) inset-ring inset-ring-(--_ring)',
indicator === 'dot' ? 'ps-2' : 'ps-1',
TONES[tone],
className
]}
data-status={status}
data-tone={tone}
>
<span
class={[
'status-badge__mark relative inline-flex shrink-0 items-center justify-center text-(--_mark)',
indicator === 'dot' ? 'size-2.5' : 'size-3.5'
]}
aria-hidden="true"
>
{#if pulse}
<span class="status-badge__pulse absolute inset-0 rounded-full bg-current"></span>
{/if}
{#if indicator === 'dot'}
<svg class="relative size-full" viewBox="0 0 8 8" fill="currentColor">
{#if icon === 'check'}
<circle cx="4" cy="4" r="3.5" />
{:else if icon === 'clock'}
<circle cx="4" cy="4" r="3.25" fill="none" stroke="currentColor" stroke-width="1" />
<path d="M4 .75a3.25 3.25 0 0 0 0 6.5Z" />
{:else if icon === 'alert'}
<path
d="M4 .75 7.5 7H.5Z"
stroke="currentColor"
stroke-width="1"
stroke-linejoin="round"
/>
{:else if icon === 'cross'}
<path d="M4 0 8 4 4 8 0 4Z" />
{:else}
<circle cx="4" cy="4" r="2.75" fill="none" stroke="currentColor" stroke-width="1.5" />
{/if}
</svg>
{:else}
<svg
class="relative size-full"
viewBox="0 0 16 16"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
{#if icon === 'check'}
<circle cx="8" cy="8" r="6.75" fill="currentColor" />
<path d="M5.25 8.25 7.1 10l3.65-4" stroke="var(--_surface)" stroke-width="1.5" />
{:else if icon === 'clock'}
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5" />
<path d="M8 5v3l2 1.25" stroke="currentColor" stroke-width="1.5" />
{:else if icon === 'alert'}
<path
d="M8 1.75 14.5 13.5h-13Z"
fill="currentColor"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M8 6.25v3" stroke="var(--_surface)" stroke-width="1.5" />
<circle cx="8" cy="11.4" r=".85" fill="var(--_surface)" />
{:else if icon === 'cross'}
<rect x="1.5" y="1.5" width="13" height="13" rx="2.5" fill="currentColor" />
<path d="m5.75 5.75 4.5 4.5m0-4.5-4.5 4.5" stroke="var(--_surface)" stroke-width="1.5" />
{:else}
<circle cx="8" cy="8" r="4" stroke="currentColor" stroke-width="1.75" />
{/if}
</svg>
{/if}
</span>
<span class="status-badge__label min-w-0 self-baseline truncate"
>{#if prefix}<span class="sr-only">{`${prefix} `}</span>{/if}{label}</span
>
</span>
<style>
/*
* Public tokens: set --status-badge-* on the badge or any ancestor to retone it. Each tone is
* one colour; its tint and ring are mixed from it below. The defaults clear 4.5:1 for the
* label against its own tint on white, zinc-50 and zinc-100 pages. --status-badge-surface is
* the colour knocked out of the filled glyphs: set it to your page colour on a dark page.
*/
.status-badge {
--_muted: var(--status-badge-muted, #52525b);
--_hairline: var(--status-badge-hairline, rgb(0 0 0 / 0.1));
--_surface: var(--status-badge-surface, #ffffff);
--_info: var(--status-badge-info, #0369a1);
--_success: var(--status-badge-success, #046c4e);
--_warning: var(--status-badge-warning, #9a4a00);
--_danger: var(--status-badge-danger, #b91c1c);
/* Formulas: the soft tint and its ring, from the tone's one ink. */
--_tint: color-mix(in srgb, var(--_tone) 8%, transparent);
--_ring: color-mix(in srgb, var(--_tone) 16%, transparent);
}
/*
* Neutral is the quietest tone: the secondary text colour, not ink, so a draft or refunded
* row never outweighs a coloured one. Its grey tint needs the hairline token as its ring.
*/
.status-badge--neutral {
--_ring: var(--_hairline);
}
/*
* Keyframes have no utility spelling. The pulse is a ring of the mark's colour easing out
* behind it; without motion permission it never paints, so the mark stands still.
*/
.status-badge__pulse {
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
.status-badge__pulse {
animation: status-badge-pulse 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
}
@keyframes status-badge-pulse {
from {
opacity: 0.4;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.25);
}
}
</style>
Usage#
On this pagePass the status key from your data, and a statuses map if the defaults (operational, degraded, outage, maintenance, pending, active, complete, failed, cancelled, draft) do not fit. It renders one static span. It does not fetch or poll status, does not decide what a status means, and is not announced when it changes unless you wrap it in a live region.
- Suggested location
src/lib/components/status-badge-01- Required props
status
Limitations
- Passing statuses replaces the default map instead of merging with it. Spread DEFAULT_STATUSES from
statuses.tsinto your map to keep the defaults. - The label never wraps. Past the width of its container it truncates with an ellipsis, so keep labels to one to three words.
- Not a live region. If the status changes in place, wrap the badge in an element with role="status" so the change is announced.
- The development warning for an unknown key reads
import.meta.env.DEV, which Vite sets; other bundlers stay silent. - Light mode only. On a dark page set
--status-badge-muted,--status-badge-hairline,--status-badge-surfaceand each tone to lighter colours, and re-check contrast.
Example
<script lang="ts">
import StatusBadge from '$lib/components/status-badge-01/StatusBadge.svelte';
import type { StatusMap } from '$lib/components/status-badge-01/statuses.js';
const orderStatuses: StatusMap = {
paid: { label: 'Paid', tone: 'success' },
refunded: { label: 'Refunded', tone: 'neutral' },
disputed: { label: 'Disputed', tone: 'warning' }
};
</script>
<StatusBadge status="operational" />
<StatusBadge status="paid" statuses={orderStatuses} prefix="Order status:" />
<StatusBadge status="degraded" indicator="dot" pulse />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Status badge#
A status badge shows a state that comes from your data: a service that is operational, an order that is paid, a job that failed. You pass the key; the map decides the label, the tone and the shape. It never changes on its own and never does anything when clicked.
The map is the design decision#
import type { StatusMap } from '$lib/components/status-badge-01/statuses.js';
const orderStatuses: StatusMap = {
paid: { label: 'Paid', tone: 'success' },
awaiting: { label: 'Awaiting payment', tone: 'info' },
refunded: { label: 'Refunded', tone: 'neutral' },
disputed: { label: 'Disputed', tone: 'warning' }
};Keep the map in one place and reuse it everywhere the status appears, so "Disputed" is the same
colour and shape on the order list, the order page and the email preview. A map you pass
replaces the defaults; spread DEFAULT_STATUSES in if you want both.
Picking a tone#
- success: done or healthy. Paid, operational, complete.
- info: in progress or scheduled, nothing wrong. Pending, maintenance, syncing.
- warning: needs a look. Degraded, disputed, overdue.
- danger: broken or blocked. Failed, outage.
- neutral: finished without a verdict, or not started. Draft, cancelled, refunded.
Each tone has its own shape (a check disc, a clock, a triangle, a square, a ring), so a column
of statuses still reads for someone who cannot tell green from red. Override icon per entry
only when the default shape says the wrong thing.
Unknown keys#
A key that is not in the map renders neutral, labelled with fallbackLabel or the key itself,
and logs a warning in development. It never renders blank, so a new backend state shows up as
something you can see and fix.
Updating in place#
The badge is not a live region. If it changes while the page is open (a deploy finishing, a payment clearing), wrap it:
<span role="status"><StatusBadge {status} /></span>Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | Yes | None | The status key from your data, such as operational or paid. |
statuses | Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; icon?: 'dot' | 'check' | 'clock' | 'alert' | 'cross' }> | No | DEFAULT_STATUSES | Map from key to label, tone and optional icon. Replaces the defaults; spread DEFAULT_STATUSES in to extend them. Without icon, each tone uses its own shape. |
indicator | 'icon' | 'dot' | No | 'icon' | A 14 px icon, or a compact 10 px mark. Both keep a different shape per tone. |
prefix | string | No | None | Visually hidden text read before the label, such as 'Order status:', for tables where the column header is not read. |
pulse | boolean | No | false | Pulse a ring out from the indicator for a live state. No animation under prefers-reduced-motion. |
fallbackLabel | string | No | None | Label for a key the map does not have. Defaults to the key itself; an empty key reads Unknown. |
class | string | No | None | Extra classes on the badge, for margins or a tighter max-width. |
Customization#
On this pageSupply your own statuses map for labels, tones and icons. Retone with --status-badge-* variables: one per tone, and each tint and ring follows. Edit TONE_ICONS in statuses.ts to change which shape a tone uses.
- Your own states: pass statuses={{ shipped:
{ label: 'Shipped', tone: 'success' }, on_hold:{ label: 'On hold', tone: 'warning', icon: 'clock' }}}. Keys match the values in your data exactly. - Keep the defaults and add to them: statuses={{ ...DEFAULT_STATUSES, archived:
{ label: 'Archived', tone: 'neutral' }}}, importing DEFAULT_STATUSES fromstatuses.ts. - Brand-aligned tones: set
--status-badge-success,--status-badge-warning,--status-badge-dangerand--status-badge-infoon the page or an ancestor. Each label has to clear 4.5:1 against its own 8% tint, so pick a step that clears about 5:1 on white. - Neutral tone:
--status-badge-mutedis its label, ring mark and tint, and--status-badge-hairlineits outline. - Dark or tinted page: set
--status-badge-mutedtozinc-400,--status-badge-hairlineto rgb(255 255 255 / 0.12),--status-badge-surfaceto the page colour, and each tone to its 300 or 400 step. - Shape: the badge is a 6 px rounded rectangle. For a pill, change
rounded-mdtorounded-fullon the root. - In a table: pass prefix="Order status:" so a screen reader moving cell by cell hears what the value is. Use indicator="dot" in dense rows.
- Live updates: wrap the badge in <span role="status"> when the value changes while the page is open.
Public CSS variables
| Variable | Token |
|---|---|
--status-badge-muted | muted |
--status-badge-hairline | hairline |
--status-badge-surface | surface |
--status-badge-info | info |
--status-badge-success | success |
--status-badge-warning | warning |
--status-badge-danger | danger |
Accessibility#
On this page- Renders a plain span with no role and no live region: a status in a table or panel is read in place, not announced. Wrap it in role="status" yourself if the value updates while the page is open.
- The label carries the meaning (WCAG 1.4.1). Each tone also has a distinct shape, so the state reads without colour, but the shape is
aria-hiddenand never the only signal. - Use prefix in tables and lists where the column header is not read with the cell, so the badge reads as 'Order status: Paid' rather than 'Paid'.
- Every default tone's label clears 4.5:1 against its tint on white,
zinc-50andzinc-100pages. Re-check if you set a tone variable. - The pulse never runs under prefers-reduced-motion; the indicator then stands still.
- A truncated label is cut visually only; the full text stays in the DOM and is read in full.
- Not focusable and not interactive.
Known limitations
- Contrast is checked for the default colours on light pages only; retoned or dark pages are the consumer's to check.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.