Toast
Transient confirmation that something happened. It disappears on its own, which means it must never carry the only copy of anything important.
Also called Notification, Snackbar, Flash Message, Growl — in this system all of them are Toast.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Appears bottom-right. Duration scales with the length of the text and caps at nine seconds.
Tones
Four tones, same structure. The danger toast is the only one that uses role="alert" and interrupts a screen reader.
Undo instead of confirm
Deleting immediately with an Undo toast is faster than a confirmation dialog and recovers just as well. The dialog interrupts everyone to protect against a rare mistake.
Stacking and duration
Newest nearest the corner, capped at four. Fire several quickly to see the cap — and note that longer messages stay longer.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Icon, title, one line of description, one action, one dismiss. Anything more than this belongs somewhere permanent.
- Width384px, capped to the viewport
About 55 characters per line — narrow enough to read in one glance, wide enough for a real sentence.
- Surface--ds-surface-overlay + e4
Overlay-level elevation, because it floats above everything including dialogs’ scrims in some flows.
- PositionBottom-right, 20px inset
Out of the reading path and away from the primary action. Top-centre collides with OS notifications.
- Duration4s + chars ÷ 18, max 9s
Computed from the text length. A fixed three seconds is unreadable for anything longer than one word.
- ActionExactly one, text style
A second action turns an announcement into a decision, and decisions must not disappear on a timer.
- Entranceslide-up 220ms emphasized
8px of travel plus a fade. Enough to catch peripheral vision without pulling the eye off the task.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Toast background |
| --ds-border | — | Edge definition |
| --ds-success-text | — | Tone icon |
| --ds-accent-text | — | Action label |
| --ds-fg-secondary | — | Description |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding | All sides | |
| stack gap | Between stacked toasts | |
| viewport inset | Distance from the corner |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| slide-up | Entrance |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Max width | When to use |
|---|---|---|---|---|---|
| Single line | 52px | 14px | — | 384px | “Copied to clipboard.” No description, no action. |
| Two line | 76px | 14px | — | 384px | Title plus one line of detail. The common case. |
| With action | 104px | 14px | — | 384px | Adds an Undo or a View link below the description. |
| Stack | — | — | 10px | 384px | Four visible maximum. Older toasts are dropped, not queued. |
| Mobile | — | — | — | calc(100vw − 40px) | Full width minus the gutters, anchored bottom above the safe area. |
onMouseEnter={pause} onFocusCapture={pause}Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The overlay surface is lighter than the page in dark mode and white with a shadow in light mode, so both need their own contrast check against the toast text.
- The action link must reach 4.5:1 against the toast background and must not rely on colour alone — ours underlines on hover and focus.
Keyboard
| Tab | Reaches the action and the dismiss button once a toast is present. |
| Esc | Dismisses the most recent toast when focus is inside the toast region. |
| F6 | Some screen readers use this to jump to the notification region. Keeping the region labelled makes that work. |
Screen readers
- The live region must be in the DOM on page load. Mounting the container and the toast together means many screen readers announce nothing.
- Keep the announcement short — the title alone is usually enough. Screen-reader users cannot skim a toast the way sighted users can.
- WCAG 2.2.1 means auto-dismiss must be adjustable or avoidable. Pausing on hover and focus, plus a persistent option, satisfies it.
Focus & touch
- A toast must never steal focus. It appears, it is announced, and the user carries on typing. Focus only moves into it if the user tabs there deliberately.
- On mobile, anchor to the bottom above the safe-area inset and make the whole toast swipeable to dismiss. Keep it clear of any bottom navigation — a toast covering the tab bar is a trap.
| Attribute | Applied to | Notes |
|---|---|---|
| role="region" + aria-label | The toast container | "Notifications". Lets a screen-reader user navigate back to it deliberately. |
| role="status" | Non-error toasts | Announced at the next pause. The container must exist before the toast is added. |
| role="alert" | Danger toasts | Interrupts. Reserved for failures the user needs to know about immediately. |
| aria-live | The container | polite or assertive to match. Setting it on the toast itself is too late — the region has to pre-exist. |
| aria-label | The dismiss button | "Dismiss notification". Never a bare ×. |
Example usage
1import { ToastProvider, useToast } from '@/ui/Feedback'23// Mount the provider once, high in the tree. The live region must4// exist before any toast is added, or nothing is announced.5<ToastProvider position="bottom-right" max={4}>6 <App />7</ToastProvider>89// Anywhere below it10const { toast, dismiss } = useToast()1112toast({ tone: 'success', title: 'Deployment queued',13 description: 'api-gateway will be live in about 40 seconds.' })1415// Undo instead of a confirmation dialog16async function deleteProject(p: Project) {17 const snapshot = p18 remove(p.id) // optimistic19 const id = toast({20 tone: 'neutral',21 title: p.name + ' deleted',22 action: { label: 'Undo', onClick: () => restore(snapshot) },23 })24 try {25 await api.delete(p.id)26 } catch {27 restore(snapshot)28 dismiss(id)29 toast({ tone: 'danger', title: 'Could not delete ' + p.name })30 }31}3233// Persistent: anything the user may need to copy34toast({35 tone: 'danger',36 title: 'Deployment failed',37 description: 'Error 4f21c-8821',38 persistent: true,39})Framework-free HTML
<!-- The region is rendered on page load and stays empty -->
<div class="ds-toasts" role="region" aria-label="Notifications">
<div class="ds-toast" role="status" aria-live="polite">
<svg class="ds-toast__icon" aria-hidden="true">…</svg>
<div class="ds-toast__content">
<p class="ds-toast__title">Deployment queued</p>
<p class="ds-toast__desc">api-gateway will be live in about 40 seconds.</p>
<button class="ds-toast__action">Undo</button>
</div>
<button class="ds-toast__dismiss" aria-label="Dismiss notification">
<svg aria-hidden="true">…</svg>
</button>
</div>
</div>CSS
.ds-toasts {
position: fixed;
inset-block-end: 0;
inset-inline-end: 0;
z-index: 90;
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
pointer-events: none; /* the stack never blocks the page */
}
.ds-toast { pointer-events: auto; }
.ds-toast {
inline-size: min(24rem, calc(100vw - 2.5rem));
display: flex;
gap: 12px;
padding: 14px;
background: var(--ds-surface-overlay);
border: 1px solid var(--ds-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-e4);
animation: slide-up 220ms var(--ease-emphasized) both;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px) }
to { opacity: 1; transform: translateY(0) }
}
/* Mobile: full width above the safe area, clear of any bottom nav */
@media (max-width: 640px) {
.ds-toasts {
inset-inline: 0;
padding: 12px;
padding-block-end: max(12px, env(safe-area-inset-bottom));
}
}
@media (prefers-reduced-motion: reduce) {
.ds-toast { animation-name: fade-in; }
}Component API
useToast()
| Prop | Type | Default | Description |
|---|---|---|---|
| toast(item) | (t: Omit<ToastItem,"id">) => string | — | Shows a toast and returns its id. |
| dismiss(id) | (id: string) => void | — | Removes a toast early — useful when an optimistic action resolves. |
| items | ToastItem[] | — | The current stack. Rarely needed outside the provider. |
ToastItem
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | string | — | One short line. This is what gets announced. |
| description | string | — | One line of detail. Two lines maximum. |
| tone | Tone | 'neutral' | danger switches the role to "alert". |
| action | { label, onClick } | — | Exactly one. Dismisses the toast after firing. |
| duration | number | — | Override the computed duration. Rarely correct. |
| persistent | boolean | false | No auto-dismiss. Use for anything copyable. |
ToastProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| position | 'bottom-right' | 'bottom-center' | 'top-right' | 'top-center' | 'bottom-right' | Bottom-right on desktop, bottom-center on mobile. |
| max | number | 4 | Older toasts are dropped rather than queued. |
Professional tips
- Collapse repeats: "3 rows deleted" instead of three separate toasts. Group by action type within a short window.
- Undo should remain available for the full toast duration, and the action must be genuinely reversible — a fake Undo that fails is worse than no Undo.
- On mobile, support swipe-to-dismiss. It is the gesture users already expect and it removes the need for a small × target.
- If a background job takes minutes, do not toast at the start and the end. Toast at the end only, or use a persistent progress surface.
Performance
- Render toasts in a portal at the body. Inside a transformed ancestor, position: fixed silently stops being fixed.
- Clear timers on unmount. A dangling setTimeout that calls setState after the provider unmounts is a classic React warning and a real leak.
- Cap the array rather than queueing. An unbounded queue during a burst of events keeps animating long after the burst is over.
- Animate transform and opacity only. A stack of four toasts animating layout properties drops frames on low-end devices.
Common mistakes
- Mounting the live region at the same moment as the toast, so screen readers announce nothing at all.
- Using a fixed three-second duration, which makes any two-line message unreadable.
- Putting the only copy of an API key, error ID or confirmation number in a toast.
- Stealing focus when a toast appears, which throws a keyboard user out of the field they were typing in.
- Placing toasts top-centre on mobile, where they collide with the OS notification shade.
Real-world recommendations
- Toast frequency is the metric that matters. If a user sees more than a handful per session, the product is narrating itself instead of just working.
- Undo-with-toast is measurably faster than confirm-then-delete for the overwhelming majority of users, and it is the pattern Gmail made standard twenty years ago.
- Keep a notification centre for anything that would otherwise need a persistent toast. Then toasts can stay genuinely transient.
- Log every toast with tone and title. A spike in danger toasts is usually the first signal of an incident, ahead of your error tracker.