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".
Sourcecomponents/feedback/alert/doc.ts · components/feedback/alert/alert.tsx · components/feedback/alert/alert.variants.ts · components/feedback/alert/alert.module.css
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 {};components/feedback/alert/alert.tsx
import type { ReactNode } from "react";
import { X } from "@/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?: ReactNode;
children: ReactNode;
/** What to do about it. */
action?: ReactNode;
/** 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, for what needs action now. */
live?: "polite" | "assertive";
onDismiss?: () => void;
dismissLabel?: string;
className?: string;
};
export function Alert({
tone,
title,
children,
action,
live,
onDismiss,
dismissLabel = "Dismiss",
className,
}: AlertProps) {
return (
<div
role={
live === "assertive"
? "alert"
: live === "polite"
? "status"
: undefined
}
className={cn(alertVariants({ tone }), className)}
>
<div className={styles.body}>
{title ? <p className={styles.title}>{title}</p> : null}
<div className={styles.text}>{children}</div>
{action ? <div className={styles.action}>{action}</div> : null}
</div>
{onDismiss ? (
<button
type="button"
className={styles.dismiss}
aria-label={dismissLabel}
onClick={onDismiss}
>
<X aria-hidden="true" />
</button>
) : null}
</div>
);
}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>;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
Sourcecomponents/feedback/progress/doc.ts · components/feedback/progress/progress.tsx · components/feedback/progress/progress.module.css
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 {};components/feedback/progress/progress.tsx
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./progress.module.css";
export type ProgressProps = Omit<
ComponentPropsWithRef<"progress">,
"value" | "max" | "children"
> & {
/** 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;
};
export function Progress({
label,
value,
max = 100,
className,
...props
}: ProgressProps) {
const limit = Number.isFinite(max) && max > 0 ? max : 100;
const amount =
value === null || !Number.isFinite(value)
? undefined
: Math.max(0, Math.min(value, limit));
return (
<progress
{...props}
aria-label={label}
max={limit}
value={amount}
className={cn(styles.root, className)}
/>
);
}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.
Sourcecomponents/feedback/skeleton/doc.ts · components/feedback/skeleton/skeleton.tsx · components/feedback/skeleton/skeleton.module.css
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 {};components/feedback/skeleton/skeleton.tsx
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./skeleton.module.css";
export type SkeletonProps = {
/** Any CSS length; fills its container by default, which is usually the
* size of the thing that is coming. */
width?: string;
height?: string;
circle?: boolean;
className?: string;
style?: CSSProperties;
};
/** 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. */
export function Skeleton({
width,
height,
circle,
className,
style,
}: SkeletonProps) {
return (
<span
aria-hidden="true"
className={cn(styles.root, circle && styles.circle, className)}
style={{ width, height, ...style }}
/>
);
}
export type SkeletonTextProps = { lines?: number; className?: string };
/** Text-shaped placeholder. The last line is short, because paragraphs end
* mid-line and a block of equal bars reads as a table. */
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
return (
<span aria-hidden="true" className={cn(styles.text, className)}>
{Array.from({ length: lines }, (_, index) => (
<span
key={index}
className={styles.root}
style={{ width: index === lines - 1 ? "62%" : "100%" }}
/>
))}
</span>
);
}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.
Sourcecomponents/feedback/toast/doc.ts · components/feedback/toast/toaster.tsx · components/feedback/toast/toast.module.css · lib/toast/store.ts
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 {};components/feedback/toast/toaster.tsx
"use client";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Button } from "@/components/forms/button";
import {
CircleAlert,
CircleCheck,
Info,
TriangleAlert,
X,
} from "@/components/utility/icon";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
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";
const EMPTY: readonly Toast[] = [];
const ICONS = {
neutral: Info,
accent: CircleCheck,
warn: TriangleAlert,
crit: CircleAlert,
};
export type ToasterProps = {
/** Names the region; the F8 shortcut is added to it. */
label?: string;
};
/**
* 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.
*/
export function Toaster({ label = "Notifications" }: ToasterProps) {
const list = useSyncExternalStore(
toasts.subscribe,
toasts.snapshot,
() => EMPTY,
);
const region = useRef<HTMLElement>(null);
// Two live regions that always exist, so every toast is announced; a
// toast appearing is not reliably announced on its own. Errors interrupt.
// Written directly: the regions are empty elements React never renders
// into, and the text is an announcement, not state.
const polite = useRef<HTMLSpanElement>(null);
const assertive = useRef<HTMLSpanElement>(null);
const announced = useRef(new Set<string>());
useEffect(() => {
for (const t of list) {
if (t.closing || announced.current.has(t.id)) continue;
announced.current.add(t.id);
const node = t.tone === "crit" ? assertive.current : polite.current;
if (!node) continue;
const text = [t.title, t.description].filter(Boolean).join(". ");
// A trailing space alternates, so a repeated message is announced again.
node.textContent = node.textContent === text ? `${text} ` : text;
}
}, [list]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "F8") {
event.preventDefault();
region.current?.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);
};
}, []);
return (
<section
ref={region}
aria-label={`${label} (F8)`}
tabIndex={-1}
className={styles.region}
onPointerEnter={toasts.pause}
onPointerLeave={toasts.resume}
onFocus={toasts.pause}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
toasts.resume();
}}
>
<ol className={styles.list}>
{list.map((t) => (
<ToastItem key={t.id} toast={t} />
))}
</ol>
<VisuallyHidden ref={polite} role="status" aria-live="polite" />
<VisuallyHidden ref={assertive} role="alert" aria-live="assertive" />
</section>
);
}
function ToastItem({ toast }: { toast: Toast }) {
const ref = useRef<HTMLLIElement>(null);
const Icon = ICONS[toast.tone];
// Dismissed: animate out, then leave the list.
useEffect(() => {
if (!toast.closing || !ref.current) return;
let live = true;
exitElement(ref.current, "slide-right").then(() => {
if (live) toasts.remove(toast.id);
});
return () => {
live = false;
};
}, [toast.closing, toast.id]);
return (
<li
ref={ref}
data-tone={toast.tone}
className={cn(surface.elevated, styles.toast)}
onKeyDown={(event) => {
if (event.key === "Escape") toasts.dismiss(toast.id);
}}
>
<span className={styles.icon} aria-hidden="true">
<Icon />
</span>
<div className={styles.text}>
<span className={styles.title}>{toast.title}</span>
{toast.description ? (
<span className={styles.description}>{toast.description}</span>
) : null}
</div>
<div className={styles.actions}>
{toast.action ? (
<Button
size="sm"
onClick={() => {
toast.action?.onAction();
toasts.dismiss(toast.id);
}}
>
{toast.action.label}
</Button>
) : null}
<Button
size="icon"
variant="quiet"
aria-label={`Dismiss: ${toast.title}`}
onClick={() => toasts.dismiss(toast.id)}
>
<X aria-hidden="true" />
</Button>
</div>
</li>
);
}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;
}
}
}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),
});