# Toast

> Transient messages in a popover="manual" region: created by the application, dismissed by a timer that pauses on hover and focus.

Page: https://svelte-lean.vercel.app/primitives/toast · Markdown: https://svelte-lean.vercel.app/primitives/toast.md · Index: https://svelte-lean.vercel.app/llms.txt

Short feedback that appears in a corner and leaves by itself, created from code with showToast(). Tier 2.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="toast".
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/toast.css';
	import { showToast } from '@svelte-lean/primitives/toast';

	let region: HTMLElement;

	function send() {
		showToast(region, {
			title: 'Invoice sent',
			description: 'harbor.studio receives it in a minute.',
			color: 'success',
			action: { label: 'Undo', value: 'undo' }
		});
	}
</script>

<button type="button" data-slean="button" onclick={send}>Send invoice</button>

<!-- One region per page, usually in the root layout. -->
<section bind:this={region} data-slean="toast" popover="manual" aria-label="Notifications">
	<ol data-slean-part="list" aria-live="polite"></ol>
</section>
```

Hover or focus the stack: every timer pauses.

## Examples

### Colors

```svelte
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral"
	onclick={() => showToast(region, { title: 'Draft saved' })}>
	Neutral
</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral"
	onclick={() => showToast(region, { title: 'Profile updated', color: 'success' })}>
	Success
</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral"
	onclick={() =>
		showToast(region, {
			title: 'Storage almost full',
			description: 'Ninety percent of the workspace quota is used.',
			color: 'warning'
		})}>
	Warning
</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral"
	onclick={() => showToast(region, { title: 'Payment declined', color: 'error' })}>
	Error
</button>
```

A status color adds its icon before the title; the title says what happened.

### Looks

```svelte
<script lang="ts">
	import { showToast } from '@svelte-lean/primitives/toast';

	let region: HTMLElement;
	const variants = ['standard', 'filled', 'outlined'] as const;
	const severities = ['success', 'info', 'warning', 'error'] as const;
</script>

{#each variants as variant}
	<div class="row">
		{#each severities as color}
			<button type="button" data-slean="button" data-variant="outlined" data-color={color}
				onclick={() => showToast(region, { title: `A ${variant} ${color} toast`, color, variant })}>
				{color}
			</button>
		{/each}
	</div>
{/each}
```

The alert's looks: standard (the soft tint, the default), filled and outlined, in the color the option names.

### Action

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import { showToast, type ToastActionDetail } from '@svelte-lean/primitives/toast';

	let region: HTMLElement;
	let archived = $state(3);

	function archive() {
		archived += 1;
		showToast(region, {
			title: 'Conversation archived',
			action: { label: 'Undo', value: 'unarchive' }
		});
	}

	// The action button's value arrives on the region.
	const actions = (node: HTMLElement) =>
		on(node, 'slean:action', (event) => {
			if ((event as CustomEvent<ToastActionDetail>).detail.value === 'unarchive') archived -= 1;
		});
</script>

<button type="button" data-slean="button" onclick={archive}>Archive</button>
<p>Archived conversations: {archived}</p>

<section bind:this={region} {@attach actions} data-slean="toast" popover="manual"
	aria-label="Notifications">
	<ol data-slean-part="list" aria-live="polite"></ol>
</section>
```

The action button’s value arrives on the region as slean:action.

### Persistent toast

```svelte
<button type="button" data-slean="button" data-variant="outlined" data-color="error"
	onclick={() =>
		showToast(region, {
			title: 'Upload failed',
			description: 'The file is larger than the workspace allows.',
			color: 'error',
			duration: 0
		})}>
	Upload
</button>
```

duration: 0 keeps the toast until the reader closes it.

### Positions

```svelte
<script lang="ts">
	import { showToast } from '@svelte-lean/primitives/toast';

	const positions = ['top-start', 'top-center', 'top-end', 'bottom-start', 'bottom-center', 'bottom-end'];
	let region: HTMLElement;
	let position = $state('bottom-end');

	function show(at: string) {
		position = at;
		showToast(region, { title: `Shown at ${at.replace('-', ' ')}` });
	}
</script>

{#each positions as at (at)}
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={() => show(at)}>
		{at}
	</button>
{/each}

<!-- data-slean-side (top, bottom) and data-slean-align (start, center, end) place the region. -->
<section bind:this={region} data-slean="toast" data-slean-side={position.split('-')[0]}
	data-slean-align={position.split('-')[1]} popover="manual" aria-label="Positioned notifications">
	<ol data-slean-part="list" aria-live="polite"></ol>
</section>
```

data-slean-side and data-slean-align place the region in one of six positions.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="toast"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/toast/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/toast.css';
```

Render one empty region per page, usually in the root layout. Call `showToast(region, options)` with a title, and optionally a description, a `color` (`info`, `success`, `warning`, `error`, …), a `variant` (`standard`, `filled`, `outlined`), a duration in milliseconds (0 keeps it) and one action.

Listen to `slean:action` on the region for the action button’s value and to `slean:dismiss` to know why a toast left; cancelling a dismissal keeps the toast.

`keep-errors.svelte`

```svelte
<script lang="ts">
	import type { ToastDismissDetail } from '@svelte-lean/primitives/toast';
	import { on } from 'svelte/events';

	// Keep an error until it has been read: cancel the timeout, allow the close button.
	function dismiss(event: Event) {
		const { item, reason } = (event as CustomEvent<ToastDismissDetail>).detail;
		if (reason === 'timeout' && item.dataset.variant === 'danger') event.preventDefault();
	}
</script>

<section
	data-slean="toast"
	popover="manual"
	aria-label="Notifications"
	{@attach (node) => on(node, 'slean:dismiss', dismiss)}
>
	<ol data-slean-part="list" aria-live="polite"></ol>
</section>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<section data-slean="toast" popover="manual" aria-label="Notifications">` | – | yes | Options: data-slean-side (top, bottom), data-slean-align (start, center, end), data-slean-duration in milliseconds. |
| list | `<ol aria-live="polite">` | `list` | yes | The live region the toasts are added to. |
| item | `<li data-color="success\|warning\|error">` | `item` | no | Created by showToast(). |
| title | `<p>` | `title` | no | Created by showToast(). |
| description | `<p>` | `description` | no | Created when a description is passed. |
| actions | `<div> with a <button type="button" data-slean-value="…">` | `actions` | no | The alert’s actions row, created when an action is passed; the button reports its value in slean:action. |
| close | `<button type="button" aria-label="Dismiss">` | `close` | no | Created on every toast. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | focus moves into the region | Reaches the action and close buttons; timers pause |
| `Enter`/`Space` | on a toast button | Runs the action or dismisses (native activation) |

## Styling

`toast.css` places the region in one of six positions (`data-slean-side` and `data-slean-align`), draws the standard, filled and outlined variants from the current color, marks a colored toast with its icon before the title, keeps the close button on the first line, and slides a new toast in. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | inset-block-end, inset-inline-end, inline-size, max-block-size, inset-block-start, inset-inline-start, padding-inline |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-color-scheme` | `light` | color-scheme |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, translate, padding-inline |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | column-gap, padding-block, padding-inline, padding-inline-start |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | row-gap, margin-block-start, padding-block, margin-inline-end |
| `--slean-color-main` | `var(--slean-primary-main)` | border, background, border-color |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-elevation-5` | `0 24px 56px oklch(0% 0 0 / 0.22), 0 4px 10px oklch(0% 0 0 / 0.1)` | box-shadow |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height, margin-block |
| `--slean-duration-normal` | `160ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-icon-info` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Ccircle cx='8' cy='8' r='6.25'/%3E%3Cpath d='M8 7.5v3.5M8 5v.01'/%3E%3C/g%3E%3C/svg%3E")` | mask |
| `--slean-icon-success` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Ccircle cx='8' cy='8' r='6.25'/%3E%3Cpath d='M5.5 8.25l1.75 1.75 3.25-3.5'/%3E%3C/g%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-warning` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Cpath d='M6.7 2.75a1.5 1.5 0 012.6 0l5.1 8.9a1.5 1.5 0 01-1.3 2.25H2.9a1.5 1.5 0 01-1.3-2.25z'/%3E%3Cpath d='M8 6.25v3M8 11.5v.01'/%3E%3C/g%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-error` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Ccircle cx='8' cy='8' r='6.25'/%3E%3Cpath d='M10 6l-4 4M6 6l4 4'/%3E%3C/g%3E%3C/svg%3E")` | mask-image |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-font-weight-semibold` | `600` | font-weight |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-control-height-sm` | `2rem` | inline-size, block-size, margin-block |
| `--slean-icon-close` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-muted` | `var(--slean-neutral-3)` | background |

State selectors the stylesheet targets, all from the platform or ARIA: `:active`, `:focus-visible`, `:hover`.

Variant attributes: `data-slean-side` (`top`); `data-slean-align` (`start`, `center`); `data-color` (`neutral`, `success`, `warning`, `error`); `data-variant` (`filled`, `outlined`).

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 1616 B brotli · 1809 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `popover="manual"`, `aria-live="polite"`, `showPopover()`, `:popover-open`
- **Shared listeners**: click, pointerover, pointerout, focusin, focusout
- **Per-instance listeners**: scoped to one interaction session
- **Lazy state**: WeakMap entry created on first interaction
- **Native base**: `popover="manual" region + aria-live list`

The toast registers five handlers (click, pointerover, pointerout, focusin, focusout) with the shared router. Its per-instance state is one timer entry per visible toast; showing fifty toasts adds no listener (`tests/toast.test.ts`).

Why this implementation

A toast has to render above everything, including an open dialog, without being closed by a click elsewhere, and a screen reader has to hear it without focus moving. `popover="manual"` gives the first: the top layer, no light dismiss. `aria-live="polite"` on the list gives the second.

What remains is time: each toast leaves after its duration, and the reader must be able to stop the clock (WCAG 2.2.1). That is ephemeral memory per toast, which is what Tier 2 is for. The timer lives in a `WeakMap` entry created by `showToast()` and deleted when the toast goes; pointer or focus in the region pauses every timer and leaving resumes them with the time that was left.

### The browser owns

- the top layer, above dialogs, without light dismiss (popover="manual")
- announcing a new toast without moving focus (aria-live="polite")
- the action and close buttons: focus, activation, their names

### Svelte Lean owns

- showToast(): the item with its title, description, action and close button
- one timer per toast in a WeakMap, created when shown and gone when dismissed
- pausing every timer while the pointer or focus is inside, resuming with the time left
- the cancelable slean:dismiss with its reason, slean:action; hiding the empty region
- toast.css: six positions, standard, filled and outlined variants in every color, the entry transition

Accessibility

- The list is `aria-live="polite"`: a new toast is announced after the current speech, and focus never moves.
- The region is shown one frame before the first toast is added, so the live region exists before its content changes.
- Hover or focus inside the region pauses every timer (WCAG 2.2.1); a toast with duration 0 stays until closed.
- The close button is named “Dismiss” (`closeLabel` changes it); the action is a native button with its label.
- For errors that must interrupt, use a second region whose list has `role="alert"`.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Popover API (manual)` | Baseline 2024 (Chrome 114, Safari 17, Firefox 125) | The region renders in place; toasts are still added and announced |
| `ARIA live regions` | Widely supported by assistive technology | Not applicable |

Without JavaScript

No toasts. Feedback that must survive without JavaScript belongs in the page, rendered by the server (the Alert primitive).

Server rendering and hydration

Render the empty region. `showToast()` is a browser call; nothing is created on the server.

Before the behavior loads there is nothing to show; the region is empty and closed.

Compatibility

The Popover API is Baseline 2024. Without it the region renders in place at its position, and toasts are still added, timed and announced.

Tests and source

- `packages/primitives/tests/toast.test.ts` Vitest: creation, timers, pause and resume, close, action, cancelable dismissal
- `apps/playground/tests/primitives/widgets.spec.ts` Playwright: the tooltip, toast, tree, range slider, number field, file drop and hover card, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/toast/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/toast/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/toast/behavior.ts` the behavior definition
- `packages/primitives/src/toast/register.ts` the registration module
- `packages/styles/css/toast.css` the optional stylesheet
