Skip to examples
Bento / Kitchen sink
Bento / primitives

Feedback

Messages about state. A message is announced when it arrives, never merely because it is on the page.

Alert

tone · title · action · live · dismiss

Heads up

Your trial ends in 5 days. Choose a plan to keep your workspace.

Saved

Your changes are live.

Usage at 90%

You have used 45 of 50 seats.

Payment failed

We could not charge the card ending 4242.

New in Bento

Workspaces can now have up to 50 members on the Team plan.

Live only when it arrives. These alerts were on the page when it loaded, so they have no live role. A message that appears after an action — a failed sign-in — passes live="assertive" or "polite".

Source src/lib/components/feedback/alert/doc.ts · src/lib/components/feedback/alert/Alert.svelte · src/lib/components/feedback/alert/alert.variants.ts · src/lib/components/feedback/alert/alert.module.css

src/lib/components/feedback/alert/doc.ts

/**
 * Alert — a message in the flow of the page.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     tone?          "neutral" | "accent" | "info" | "warn" | "crit"
 *     title?         content
 *     children       the message
 *     action?        what to do about it
 *     live?          "polite" | "assertive"
 *     onDismiss?     shows a close button when given
 *     dismissLabel?  default "Dismiss"
 *
 * # Behaviour
 *
 * R1  Framed in the tone's line and tint; the title in the tone's colour, the
 *     message in `--ink` for contrast.
 * R2  No live role unless `live` is set. "polite" is a status (announced when
 *     the reader is free); "assertive" is an alert (announced at once). Use
 *     them for messages that appear after an action, like a failed sign-in.
 * R3  The dismiss button is named by `dismissLabel`. Dismissing is the
 *     caller's state: the alert does not hide itself.
 */
export {};

src/lib/components/feedback/alert/Alert.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { X } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import styles from './alert.module.css';
	import { alertVariants, type AlertVariants } from './alert.variants';

	export type AlertProps = AlertVariants & {
		title?: string;
		/** What to do about it. */
		action?: Snippet;
		/** How the alert is announced when it APPEARS. Absent for a message that
		 *  is part of the page: it is read in order like any prose. "polite"
		 *  waits for the reader to finish; "assertive" interrupts. */
		live?: 'polite' | 'assertive';
		ondismiss?: () => void;
		dismissLabel?: string;
		class?: string;
		children?: Snippet;
	};

	let {
		tone,
		title,
		action,
		live,
		ondismiss,
		dismissLabel = 'Dismiss',
		class: className = '',
		children
	}: AlertProps = $props();
</script>

<div
	role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
	class={cn(alertVariants({ tone }), className)}
>
	<div class={styles.body}>
		{#if title}<p class={styles.title}>{title}</p>{/if}
		<div class={styles.text}>{@render children?.()}</div>
		{#if action}<div class={styles.action}>{@render action()}</div>{/if}
	</div>
	{#if ondismiss}
		<button type="button" class={styles.dismiss} aria-label={dismissLabel} onclick={ondismiss}>
			<X aria-hidden="true" />
		</button>
	{/if}
</div>

src/lib/components/feedback/alert/alert.variants.ts

import { cva, type VariantProps } from 'class-variance-authority';

import styles from './alert.module.css';

export const alertVariants = cva(styles.root, {
	variants: {
		tone: {
			neutral: styles.neutral,
			accent: styles.accent,
			info: styles.info,
			warn: styles.warn,
			crit: styles.crit
		}
	},
	defaultVariants: { tone: 'neutral' }
});

export type AlertVariants = VariantProps<typeof alertVariants>;

src/lib/components/feedback/alert/alert.module.css

@layer primitive {
	.root {
		display: flex;
		align-items: flex-start;
		gap: var(--space-5);
		padding: var(--space-5) var(--space-6);
		border: 1px solid;
		border-radius: var(--radius-2);
		font-size: var(--text-body, var(--text-13));
		line-height: var(--leading-snug);
	}
	.body {
		display: grid;
		min-width: 0;
		flex: 1;
		gap: var(--space-2);
	}
	.title {
		margin: 0;
		font-weight: var(--weight-strong);
	}
	/* Body text stays in ink for contrast; the tone lives in the frame and
     title. */
	.text {
		color: var(--ink);
	}
	.action {
		margin-top: var(--space-3);
	}
	.dismiss {
		display: grid;
		width: var(--control-sm);
		height: var(--control-sm);
		flex: none;
		place-items: center;
		margin: calc(var(--space-2) * -1) calc(var(--space-3) * -1) 0 0;
		padding: 0;
		border-radius: var(--radius-1);
		color: inherit;
		cursor: pointer;
	}
	.dismiss:hover {
		background: var(--surface-hover-2);
	}
	.dismiss:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
	}
	.dismiss svg {
		width: 14px;
		height: 14px;
	}

	.neutral {
		border-color: var(--line);
		background: var(--surface-panel-2);
		color: var(--ink-2);
	}
	.accent {
		border-color: var(--accent-line);
		background: var(--accent-tint);
		color: var(--accent);
	}
	.info {
		border-color: var(--info-line);
		background: var(--info-tint);
		color: var(--info);
	}
	.warn {
		border-color: var(--warn-line);
		background: var(--warn-tint);
		color: var(--warn);
	}
	.crit {
		border-color: var(--crit-line);
		background: var(--crit-tint);
		color: var(--crit);
	}
}

Progress

label · value · unknown

value
unknown
Source src/lib/components/feedback/progress/doc.ts · src/lib/components/feedback/progress/Progress.svelte · src/lib/components/feedback/progress/progress.module.css

src/lib/components/feedback/progress/doc.ts

/**
 * Progress — how far along a task is.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label   string, REQUIRED — what is progressing
 *     value   number, or null when the amount is unknown
 *     max?    number, default 100
 *
 * # Behaviour
 *
 * R1  A full-width pill track on `--surface-sunk`, filled with `--fill`.
 * R2  The value is clamped between 0 and max; a non-finite max becomes 100.
 * R3  `null` shows moving stripes instead of a bar, rather than a bar that
 *     claims a position nobody knows.
 * R4  Announced as a progress bar with its label and percentage.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The native progress element: its role, value, and indeterminate state are
 * built in and announced without any ARIA.
 */
export {};

src/lib/components/feedback/progress/Progress.svelte

<script lang="ts">
	import type { HTMLProgressAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import styles from './progress.module.css';

	export type ProgressProps = Omit<HTMLProgressAttributes, 'class' | 'value' | 'max'> & {
		/** What is progressing. Required: a bare bar says nothing to a reader. */
		label: string;
		/** The amount done, or null when the amount is unknown. */
		value: number | null;
		max?: number;
		class?: string;
	};

	let { label, value, max = 100, class: className = '', ...rest }: ProgressProps = $props();

	const limit = $derived(Number.isFinite(max) && max > 0 ? max : 100);
	const amount = $derived(
		value === null || !Number.isFinite(value) ? undefined : Math.max(0, Math.min(value, limit))
	);
</script>

<progress {...rest} aria-label={label} max={limit} value={amount} class={cn(styles.root, className)}
></progress>

src/lib/components/feedback/progress/progress.module.css

@layer primitive {
	.root {
		display: block;
		width: 100%;
		height: var(--space-4);
		overflow: hidden;
		appearance: none;
		border: 0;
		border-radius: var(--radius-pill);
		background: var(--surface-sunk);
		color: var(--fill);
	}
	.root::-webkit-progress-bar {
		border-radius: inherit;
		background: var(--surface-sunk);
	}
	.root::-webkit-progress-value {
		border-radius: inherit;
		background: var(--fill);
		transition: width var(--dur-3) var(--ease);
	}
	.root::-moz-progress-bar {
		border-radius: inherit;
		background: var(--fill);
	}
	/* Unknown amount: moving stripes rather than a bar that lies about how far
     along it is. */
	.root:indeterminate {
		background: repeating-linear-gradient(110deg, var(--fill) 0 10px, var(--accent-tint) 10px 20px)
			0 0 / 200% 100%;
		animation: stripes 1.6s linear infinite;
	}
	.root:indeterminate::-webkit-progress-bar {
		background: transparent;
	}
	.root:indeterminate::-moz-progress-bar {
		background: transparent;
	}
	@keyframes stripes {
		to {
			background-position: -100% 0;
		}
	}
}

Skeleton

placeholders; the region is busy

Skeletons are silent. Each is hidden from assistive technology; the region around them is aria-busy, which is one announcement instead of one per bar.

Source src/lib/components/feedback/skeleton/doc.ts · src/lib/components/feedback/skeleton/Skeleton.svelte · src/lib/components/feedback/skeleton/SkeletonText.svelte · src/lib/components/feedback/skeleton/skeleton.module.css

src/lib/components/feedback/skeleton/doc.ts

/**
 * Skeleton — a placeholder shaped like content that has not arrived.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Skeleton       width?, height? (CSS lengths), circle?
 *     SkeletonText   lines? (default 3)
 *
 * # Behaviour
 *
 * R1  Always hidden from assistive technology. The region it fills carries
 *     `aria-busy` instead: one announcement, not one per placeholder.
 * R2  A skeleton fills its container unless sized, and is 1em tall.
 * R3  A shimmer moves across it; under reduced motion it stops and the flat
 *     colour still reads as "not yet".
 * R4  SkeletonText's last line is short, because paragraphs end mid-line.
 */
export {};

src/lib/components/feedback/skeleton/Skeleton.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import styles from './skeleton.module.css';

	/* A placeholder for content that has not arrived. Always hidden from
	   assistive technology: put aria-busy on the region instead, which is one
	   announcement rather than one per skeleton. */
	let {
		width,
		height,
		circle = false,
		class: className = ''
	}: {
		/** Any CSS length; fills its container by default. */
		width?: string;
		height?: string;
		circle?: boolean;
		class?: string;
	} = $props();
</script>

<span
	aria-hidden="true"
	class={cn(styles.root, circle && styles.circle, className)}
	style:width
	style:height
></span>

src/lib/components/feedback/skeleton/SkeletonText.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import styles from './skeleton.module.css';

	/* Text-shaped placeholder. The last line is short, because paragraphs end
	   mid-line and a block of equal bars reads as a table. */
	let { lines = 3, class: className = '' }: { lines?: number; class?: string } = $props();
</script>

<span aria-hidden="true" class={cn(styles.text, className)}>
	{#each { length: lines }, index (index)}
		<span class={styles.root} style:width={index === lines - 1 ? '62%' : '100%'}></span>
	{/each}
</span>

src/lib/components/feedback/skeleton/skeleton.module.css

@layer primitive {
	.root {
		display: block;
		width: 100%;
		height: 1em;
		border-radius: var(--radius-1);
		background: var(--surface-hover-2)
			linear-gradient(90deg, transparent, var(--surface-hover), transparent) no-repeat;
		background-size: 200% 100%;
		animation: shimmer 1.4s ease-in-out infinite;
	}
	/* Height follows width; the default 1em would flatten it into a pill. */
	.circle {
		height: auto;
		flex: none;
		aspect-ratio: 1;
		border-radius: var(--radius-pill);
	}
	.text {
		display: grid;
		gap: var(--space-3);
	}
	/* Reduced motion stops the shimmer; the colour still says "not yet". */
	@media (prefers-reduced-motion: reduce) {
		.root {
			animation: none;
		}
	}
	@keyframes shimmer {
		from {
			background-position: 200% 0;
		}
		to {
			background-position: -200% 0;
		}
	}
}

Toast

tones · undo · errors stay · F8

Archived projects: 0

Call it from anywhere. toast() is a plain function over a store both apps share; one Toaster, mounted at the root, renders it.

Announced, and never lost. Toasts are read out through live regions that are always on the page; errors interrupt. An error stays until dismissed, and timers pause while the pointer or focus is on the toasts. F8 moves focus to them; Escape dismisses the focused one.

They leave, too. A dismissed toast animates out before it is removed — the exit half of the motion helper.

Source src/lib/components/feedback/toast/doc.ts · src/lib/components/feedback/toast/Toaster.svelte · src/lib/components/feedback/toast/ToastItem.svelte · src/lib/components/feedback/toast/toast.module.css · src/lib/toast/store.ts

src/lib/components/feedback/toast/doc.ts

/**
 * Toast — a short message about something that just happened.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     toast({ title, description?, tone?, action?, duration?, id? }) → id
 *     toast.dismiss(id)
 *     <Toaster label? />   mounted once, near the root
 *
 *     tone      "neutral" (default) | "accent" | "warn" | "crit"
 *     action    { label, onAction } — one, such as Undo
 *     duration  ms, or null to stay; defaults 5s, 8s with an action, and
 *               errors stay
 *
 * # Behaviour
 *
 * R1  Every toast is announced through live regions that are always present:
 *     politely, or assertively for errors.
 * R2  An error stays until dismissed. Nothing important may live only in a
 *     toast that disappears on its own.
 * R3  Timers pause while the pointer or focus is on the toasts, or the page
 *     is hidden; time already spent is kept.
 * R4  F8 moves focus to the toasts; Escape dismisses the focused one; every
 *     toast has a dismiss button named after it.
 * R5  Running the action dismisses the toast.
 * R6  At most four show; a fifth dismisses the oldest. Reusing an id
 *     replaces that toast.
 * R7  A dismissed toast animates out before it is removed (instantly under
 *     reduced motion).
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * lib/toast/store.ts is framework-free and shared by both apps; it holds the
 * list and the timers. The Toaster subscribes, writes announcements into its
 * two live regions, and on dismissal runs the motion helper's exitElement
 * before telling the store to remove the toast.
 */
export {};

src/lib/components/feedback/toast/Toaster.svelte

<script lang="ts">
	import { onMount } from 'svelte';
	import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
	import { toasts } from '$lib/toast';
	import styles from './toast.module.css';
	import ToastItem from './ToastItem.svelte';

	/*
	 * Where toasts appear. Mount once, near the root; call `toast()` anywhere.
	 * F8 moves focus to the toasts. Timers pause while the pointer or focus is
	 * on them, or the page is hidden.
	 */
	let { label = 'Notifications' }: { label?: string } = $props();

	let region = $state<HTMLElement>();
	let polite = $state('');
	let assertive = $state('');
	// Bookkeeping, not state: which toasts were already announced. Nothing
	// renders from it, so it is deliberately not reactive.
	// eslint-disable-next-line svelte/prefer-svelte-reactivity
	const announced = new Set<string>();

	// Two live regions that always exist, so every toast is announced; a toast
	// appearing is not reliably announced on its own. Errors interrupt.
	$effect(() => {
		for (const t of $toasts) {
			if (t.closing || announced.has(t.id)) continue;
			announced.add(t.id);
			const text = [t.title, t.description].filter(Boolean).join('. ');
			// A trailing space alternates, so a repeated message is announced again.
			if (t.tone === 'crit') assertive = assertive === text ? `${text} ` : text;
			else polite = polite === text ? `${text} ` : text;
		}
	});

	onMount(() => {
		const onKey = (event: KeyboardEvent) => {
			if (event.key === 'F8') {
				event.preventDefault();
				region?.focus();
			}
		};
		const onVisibility = () => (document.hidden ? toasts.pause() : toasts.resume());
		window.addEventListener('keydown', onKey);
		document.addEventListener('visibilitychange', onVisibility);
		return () => {
			window.removeEventListener('keydown', onKey);
			document.removeEventListener('visibilitychange', onVisibility);
		};
	});
</script>

<section
	bind:this={region}
	aria-label="{label} (F8)"
	tabindex="-1"
	class={styles.region}
	onpointerenter={toasts.pause}
	onpointerleave={toasts.resume}
	onfocusin={toasts.pause}
	onfocusout={(event) => {
		if (!event.currentTarget.contains(event.relatedTarget as Node | null)) toasts.resume();
	}}
>
	<ol class={styles.list}>
		{#each $toasts as t (t.id)}
			<ToastItem toast={t} />
		{/each}
	</ol>
	<VisuallyHidden role="status" aria-live="polite">{polite}</VisuallyHidden>
	<VisuallyHidden role="alert" aria-live="assertive">{assertive}</VisuallyHidden>
</section>

src/lib/components/feedback/toast/ToastItem.svelte

<script lang="ts">
	import { Button } from '$lib/components/forms/button';
	import { CircleAlert, CircleCheck, Info, TriangleAlert, X } from '$lib/components/utility/icon';
	import { exitElement } from '$lib/motion';
	import { toasts, type Toast } from '$lib/toast';
	import { cn } from '$lib/utils/cn';
	import surface from '../../surface.module.css';
	import styles from './toast.module.css';

	let { toast }: { toast: Toast } = $props();
	const ICONS = { neutral: Info, accent: CircleCheck, warn: TriangleAlert, crit: CircleAlert };
	const Icon = $derived(ICONS[toast.tone]);
	let item = $state<HTMLLIElement>();

	// Dismissed: animate out, then leave the list.
	$effect(() => {
		if (!toast.closing || !item) return;
		let live = true;
		const id = toast.id;
		exitElement(item, 'slide-right').then(() => {
			if (live) toasts.remove(id);
		});
		return () => {
			live = false;
		};
	});
</script>

<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
	bind:this={item}
	data-tone={toast.tone}
	class={cn(surface.elevated, styles.toast)}
	onkeydown={(event) => {
		if (event.key === 'Escape') toasts.dismiss(toast.id);
	}}
>
	<span class={styles.icon} aria-hidden="true"><Icon /></span>
	<div class={styles.text}>
		<span class={styles.title}>{toast.title}</span>
		{#if toast.description}<span class={styles.description}>{toast.description}</span>{/if}
	</div>
	<div class={styles.actions}>
		{#if toast.action}
			<Button
				size="sm"
				onclick={() => {
					toast.action?.onAction();
					toasts.dismiss(toast.id);
				}}>{toast.action.label}</Button
			>
		{/if}
		<Button
			size="icon"
			variant="quiet"
			aria-label="Dismiss: {toast.title}"
			onclick={() => toasts.dismiss(toast.id)}
		>
			<X aria-hidden="true" />
		</Button>
	</div>
</li>

src/lib/components/feedback/toast/toast.module.css

@layer composition {
	/* The region always exists (live regions must, to be announced), but only
     its toasts take the pointer. */
	.region {
		position: fixed;
		z-index: var(--z-toast);
		right: var(--space-7);
		bottom: var(--space-7);
		width: min(24rem, calc(100vw - var(--space-7) * 2));
		pointer-events: none;
	}
	.region:focus-visible {
		outline: none;
	}
	.list {
		display: grid;
		gap: var(--space-4);
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.toast {
		display: grid;
		grid-template-columns: auto minmax(0, 1fr) auto;
		align-items: start;
		gap: var(--space-4);
		padding: var(--space-5) var(--space-5) var(--space-5) var(--space-6);
		border-left: 3px solid var(--tone, var(--line-strong));
		pointer-events: auto;
		animation: toast-in var(--dur-3) var(--ease);
	}
	.toast[data-tone='accent'] {
		--tone: var(--accent);
	}
	.toast[data-tone='warn'] {
		--tone: var(--warn);
	}
	.toast[data-tone='crit'] {
		--tone: var(--crit);
	}
	.icon {
		display: grid;
		width: 18px;
		height: 20px;
		place-items: center;
		color: var(--tone, var(--ink-3));
	}
	.icon svg {
		width: 16px;
		height: 16px;
	}
	.text {
		display: grid;
		gap: var(--space-1);
		min-width: 0;
	}
	.title {
		color: var(--ink);
		font-size: var(--text-13);
		font-weight: var(--weight-strong);
		line-height: var(--leading-snug);
	}
	.description {
		color: var(--ink-2);
		font-size: var(--text-12);
		line-height: var(--leading-snug);
		overflow-wrap: anywhere;
	}
	.actions {
		display: flex;
		align-items: center;
		gap: var(--space-2);
	}
	@keyframes toast-in {
		from {
			opacity: 0;
			transform: translateY(12px);
		}
	}
	@media (max-width: 40rem) {
		.region {
			right: var(--space-4);
			bottom: var(--space-4);
			left: var(--space-4);
			width: auto;
		}
	}
}

src/lib/toast/store.ts

/* Toasts: a framework-free store both apps share. It holds the list and the
   timers; a Toaster component renders it (React through
   useSyncExternalStore, Svelte through the store contract — subscribe calls
   back at once with the current value). */

export type ToastTone = 'neutral' | 'accent' | 'warn' | 'crit';

export type ToastInput = {
	title: string;
	description?: string;
	tone?: ToastTone;
	/** One action, such as Undo. Running it dismisses the toast. */
	action?: { label: string; onAction: () => void };
	/** Milliseconds on screen; null stays until dismissed. Defaults: 5s, 8s
	 *  with an action (time to undo), and errors stay. */
	duration?: number | null;
	/** Reuse an id to replace a toast rather than stack another. */
	id?: string;
};

export type Toast = Required<Pick<ToastInput, 'title' | 'tone'>> &
	Omit<ToastInput, 'title' | 'tone' | 'duration' | 'id'> & {
		id: string;
		duration: number | null;
		/** Dismissed, and animating out; the Toaster removes it after. */
		closing: boolean;
	};

/** More than this and the oldest is dismissed. */
export const MAX_TOASTS = 4;

class ToastStore {
	#toasts: Toast[] = [];
	#listeners = new Set<(toasts: readonly Toast[]) => void>();
	#timers = new Map<
		string,
		{
			handle?: ReturnType<typeof setTimeout>;
			remaining: number;
			started: number;
		}
	>();
	#paused = false;
	#count = 0;

	subscribe = (run: (toasts: readonly Toast[]) => void) => {
		this.#listeners.add(run);
		run(this.#toasts);
		return () => {
			this.#listeners.delete(run);
		};
	};

	/** The current list: React's useSyncExternalStore snapshot. */
	snapshot = () => this.#toasts;

	#emit() {
		this.#toasts = [...this.#toasts];
		for (const run of this.#listeners) run(this.#toasts);
	}

	show = (input: ToastInput): string => {
		const tone = input.tone ?? 'neutral';
		const toast: Toast = {
			...input,
			id: input.id ?? `toast-${++this.#count}`,
			tone,
			duration:
				input.duration !== undefined
					? input.duration
					: tone === 'crit'
						? null
						: input.action
							? 8000
							: 5000,
			closing: false
		};
		this.#clear(toast.id);
		const existing = this.#toasts.findIndex((t) => t.id === toast.id);
		if (existing >= 0) this.#toasts[existing] = toast;
		else this.#toasts.push(toast);
		const open = this.#toasts.filter((t) => !t.closing);
		if (open.length > MAX_TOASTS) this.dismiss(open[0].id);
		this.#start(toast);
		this.#emit();
		return toast.id;
	};

	/** Start closing: the Toaster animates it out, then calls remove. */
	dismiss = (id: string) => {
		const toast = this.#toasts.find((t) => t.id === id);
		if (!toast || toast.closing) return;
		this.#clear(id);
		toast.closing = true;
		this.#emit();
	};

	remove = (id: string) => {
		this.#clear(id);
		this.#toasts = this.#toasts.filter((t) => t.id !== id);
		this.#emit();
	};

	/** Hold every timer: the pointer or focus is on the toasts, or the page is
	 *  hidden. Time already spent is kept. */
	pause = () => {
		if (this.#paused) return;
		this.#paused = true;
		for (const [id, timer] of this.#timers) {
			if (timer.handle === undefined) continue;
			clearTimeout(timer.handle);
			timer.handle = undefined;
			timer.remaining -= Date.now() - timer.started;
			this.#timers.set(id, timer);
		}
	};

	resume = () => {
		if (!this.#paused) return;
		this.#paused = false;
		for (const [id, timer] of this.#timers) this.#arm(id, timer);
	};

	#start(toast: Toast) {
		if (toast.duration === null || typeof window === 'undefined') return;
		const timer = { remaining: toast.duration, started: Date.now() };
		this.#timers.set(toast.id, timer);
		if (!this.#paused) this.#arm(toast.id, timer);
	}

	#arm(
		id: string,
		timer: {
			handle?: ReturnType<typeof setTimeout>;
			remaining: number;
			started: number;
		}
	) {
		timer.started = Date.now();
		timer.handle = setTimeout(() => this.dismiss(id), Math.max(0, timer.remaining));
	}

	#clear(id: string) {
		const timer = this.#timers.get(id);
		if (timer?.handle !== undefined) clearTimeout(timer.handle);
		this.#timers.delete(id);
	}
}

export const toasts = new ToastStore();

/** Show a toast; returns its id. `toast.dismiss(id)` closes one early. */
export const toast = Object.assign((input: ToastInput) => toasts.show(input), {
	dismiss: (id: string) => toasts.dismiss(id)
});