Skip to content
Palette Neutral
Download ZIP

Neutral palette · 6.7 KB ZIP File receipt View as Markdown View code

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.
cmp_status_badge_01 · version 1.0.0 · Neutral palette
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

StatusBadge.svelte Svelte · 7.1 KB Raw
<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>

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.

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

Example

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

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#

TypeScript
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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
statusstringYesNoneThe status key from your data, such as operational or paid.
statusesRecord<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; icon?: 'dot' | 'check' | 'clock' | 'alert' | 'cross' }>NoDEFAULT_STATUSESMap 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.
prefixstringNoNoneVisually hidden text read before the label, such as 'Order status:', for tables where the column header is not read.
pulsebooleanNofalsePulse a ring out from the indicator for a live state. No animation under prefers-reduced-motion.
fallbackLabelstringNoNoneLabel for a key the map does not have. Defaults to the key itself; an empty key reads Unknown.
classstringNoNoneExtra classes on the badge, for margins or a tighter max-width.

Customization#

On this page

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.

Public CSS variables

VariableToken
--status-badge-mutedmuted
--status-badge-hairlinehairline
--status-badge-surfacesurface
--status-badge-infoinfo
--status-badge-successsuccess
--status-badge-warningwarning
--status-badge-dangerdanger

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

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.