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
Saved
Usage at 90%
Payment failed
New in Bento
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
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)
});