# 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.

- ID: `cmp_status_badge_01`
- Slug: `status-badge-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T20:06:48Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/status-badge-01?variant=neutral
- Preview: https://pagesugar.com/preview/status-badge-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/status-badge-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass 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.

Required props: `status`

```svelte
<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 />
```

Limitations:

- Passing statuses replaces the default map instead of merging with it. Spread DEFAULT\_STATUSES from statuses.ts into 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-surface and each tone to lighter colours, and re-check contrast.

## Usage guide

### 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

```ts
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:

```svelte
<span role="status"><StatusBadge {status} /></span>
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `status` | `string` | yes |  | 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 |  | 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 |  | Label for a key the map does not have. Defaults to the key itself; an empty key reads Unknown. |
| `class` | `string` | no |  | Extra classes on the badge, for margins or a tighter max-width. |

## Customization

Supply 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 from statuses.ts.
- Brand-aligned tones: set --status-badge-success, --status-badge-warning, --status-badge-danger and --status-badge-info on 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-muted is its label, ring mark and tint, and --status-badge-hairline its outline.
- Dark or tinted page: set --status-badge-muted to zinc-400, --status-badge-hairline to rgb(255 255 255 / 0.12), --status-badge-surface to 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-md to rounded-full on 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.

| Token | Public CSS variable |
| --- | --- |
| `muted` | `--status-badge-muted` |
| `hairline` | `--status-badge-hairline` |
| `surface` | `--status-badge-surface` |
| `info` | `--status-badge-info` |
| `success` | `--status-badge-success` |
| `warning` | `--status-badge-warning` |
| `danger` | `--status-badge-danger` |

## Accessibility

- 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-hidden and 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-50 and zinc-100 pages. 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `StatusBadge.svelte`
- Suggested directory: `src/lib/components/status-badge-01`
- Files: 2
- Artifact digest: `sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `StatusBadge.svelte`

Role: entry · 7233 bytes · SHA-256 `e5079de8c3544ad5c07e06e45cfa26f781236a8720f1293500ec5492874df259`

```svelte
<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>
```

#### `statuses.ts`

Role: helper · 1919 bytes · SHA-256 `21a58f7ca055c57d9b34e1291f77d61bf677b2f2e4ec3b5fa977daf625968451`

```ts
/** The five tones a status can take. Each one has its own colour token and default shape. */
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';

/**
 * The indicator drawn before the label. Every name is a different silhouette, so two statuses
 * never differ by colour alone: a filled disc with a check, a clock ring, a triangle, a
 * rounded square with a cross, and a hollow ring.
 */
export type StatusIcon = 'dot' | 'check' | 'clock' | 'alert' | 'cross';

/** How one status key renders. */
export interface StatusDefinition {
	/** The visible label, such as "Operational". */
	label: string;
	/** Colour role. */
	tone: StatusTone;
	/** Indicator shape. Omitted, the tone's default shape from TONE_ICONS is used. */
	icon?: StatusIcon;
}

/** A map from the status keys in your data to how each one renders. */
export type StatusMap = Record<string, StatusDefinition>;

/** The default shape for each tone. No two tones share one. */
export const TONE_ICONS: Record<StatusTone, StatusIcon> = {
	neutral: 'dot',
	info: 'clock',
	success: 'check',
	warning: 'alert',
	danger: 'cross'
};

/**
 * Keys that cover a status page and a simple job or order lifecycle. Passing `statuses`
 * replaces this map rather than merging with it; spread it in to extend it:
 * `{ ...DEFAULT_STATUSES, refunded: { label: 'Refunded', tone: 'neutral' } }`.
 */
export const DEFAULT_STATUSES: StatusMap = {
	operational: { label: 'Operational', tone: 'success' },
	degraded: { label: 'Degraded', tone: 'warning' },
	outage: { label: 'Outage', tone: 'danger' },
	maintenance: { label: 'Maintenance', tone: 'info' },
	pending: { label: 'Pending', tone: 'info' },
	active: { label: 'Active', tone: 'success' },
	complete: { label: 'Complete', tone: 'success' },
	failed: { label: 'Failed', tone: 'danger' },
	cancelled: { label: 'Cancelled', tone: 'neutral' },
	draft: { label: 'Draft', tone: 'neutral' }
};
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e`
- Entry: `StatusBadge.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_status_badge_01/1.0.0/neutral/sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_status_badge_01/1.0.0/neutral/sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e/bundle.zip (6819 bytes, sha256 `66aaa31a90d3b18b32daaf4fe60a1a035919a0cc900daa991d99f161971e4793`)

Files:

- `StatusBadge.svelte` (entry, 7233 bytes): https://pagesugar.com/artifacts/cmp_status_badge_01/1.0.0/neutral/sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e/source/StatusBadge.svelte
- `statuses.ts` (helper, 1919 bytes): https://pagesugar.com/artifacts/cmp_status_badge_01/1.0.0/neutral/sha256-4ea359674ab3ab10d4d65d76407a113a2f0e6dc09e91e19b09b03e289b11cc4e/source/statuses.ts
