Toast
Real provider-managed, non-modal notifications with meaningful priority, dismissal and recoverable task outcomes.
Brief feedback and persistent recovery
Use a toast for supplementary feedback about a task outcome. Keep important failure details and recovery available in the task. A short notification cannot prove that a save, review, permission change or publication succeeded.
The Material snackbar family corresponds here to a provider-managed notification with an optional task action. This implementation is a web flow stack; it does not claim native Material physics or an exact Material visual/API implementation. A persistent inline message or dialog may be appropriate for other needs.
Provider and component anatomy
| Export | Role / current contract |
|---|---|
| ToastProvider | Owns lifecycle, timer pausing and visible limit; wraps both triggers and renderer. |
| useToastManager | Read reactive toasts; add/update/close/promise through the current provider. |
| ToastViewport | Named notification region with primitive keyboard/live behavior; default fixed, width min(26rem, viewport − 2rem). |
| Toast | Root requires an actual toast object; div ref and default/success/error/warning styling. |
| ToastContent | Primitive content wrapper; preserve the title/description/action structure. |
| ToastTitle / ToastDescription | Connected name/description, not arbitrary detached spans. |
| ToastAction | Named 44px action button; caller implements the actual recovery. |
| ToastClose | 44px close button; named default, caller override and custom children supported. |
| ToastPortal | Optional body portal; local theme variables need an explicit portal scope. |
| createToastManager | Factory for a provider-bound external manager; avoid a shared server singleton. |
Snackbar reference and delivery policy
Material’s snackbar is low-priority feedback for a performed process: one visible message at a time, at most one task action plus optional dismissal, and no focus grab or modal trap. This package’s default Toast provider is a general three-item stack. Select One visible notification in the example to exercise a provider limit of one. Change policy only with no existing notifications, so a mode change does not discard the current task. This is a visible-limit policy, not a first-in-first-out delivery queue; limited objects remain inert/hidden and can become visible after a newer item closes.
Ordinary examples optionally expire after five seconds, within the reference’s common 4–10 second platform range. Their latest feedback remains in the inline task text after the toast disappears. It is supplementary, non-live inline text; the provider owns the notification announcement. Undo stays persistent and has a separate restore control even if another notification displaces it. Actionable feedback must not depend on a short timeout.
| Reference point | Implemented web choice |
|---|---|
| Anatomy and roles | Container, supporting text, optional action and close. Material uses inverse-surface/on-surface/primary roles; this library uses its opaque card/foreground/muted-text roles and explicit semantic variants. Color does not imply assertive announcement. |
| Message and action | Prefer concise plain process text and a single text action. Avoid decorative icons, stylized copy and inline links; choose an inline message or dialog for richer or required decisions. ToastAction is the local secondary-button preset, not an inverse-primary Material text-button API. |
| Wrapping and dimensions | The reference recommends one line when possible, two in compact layouts and a separate third line for a long action. Local text grows and wraps instead of truncating essential content at 200% text. Native 48–64dp container guidance is not a fixed web height; this component has 1rem padding and a 44px close control. In notification regions up to 20rem wide, the close control moves into flow and releases the reserved inline-end column so enlarged words retain their width. Wider regions retain the corner close control. |
| Placement and occlusion | Reference snackbars sit inset at the bottom and above FABs; they must not obscure navigation, frequent targets or focused controls. The default fixed viewport requires consuming-layout clearance. This lab intentionally uses an in-flow bounded viewport so it cannot cover the reading task; it does not implement automatic FAB displacement. |
| Consecutive messages | Do not arrange snackbars beside each other or stack them under the Material delivery policy. The explicit single-visible mode exercises the alternate provider limit, while the stack mode remains available for general notification use. Updating an existing ID replaces that message’s stale information. |
| Keyboard and announcement | Keep routine arrivals polite, without moving focus. Document a shortcut to reach actions: this pinned primitive uses F6, rather than the reference’s example Alt+G. Tab moves through actions; Escape dismisses a focused toast; return to the originating or next meaningful task control. No physical screen-reader result is inferred from DOM checks. |
Working manager, promise and undo
The example uses the actual exported provider and objects rather than timed raw HTML. It supports variant selection, add/update, a selectable one/three-item visible limit, retained ordinary feedback, optional ordinary expiry, manual promise success/failure and local undo. The viewport remains in the document; its inline style is included in the copyable source.
The undo notification has timeout=0 and a separate persistent restore control. Provider limit can displace an old notification, so recoverability does not depend on notification survival. No record is deleted or published.
These are real provider-managed notifications for local examples. The viewport is in the document so it cannot cover reading content.
Dismiss notifications before changing delivery. Existing task state stays retained.
No ordinary notification yet.
Local example draft is present.
Provider limit: 3. The one-visible mode uses the provider’s limit; it does not promise first-in-first-out delivery. Ordinary feedback remains above after expiry. Undo has no expiry and a separate persistent restore control. F6 enters the viewport; Shift+Tab returns from the viewport to the task. Escape dismisses a focused notification. Verify your actual browser and screen reader before release.
'use client';
import { useRef, useState } from 'react';
import { Button, Toast, ToastAction, ToastClose, ToastContent, ToastDescription, ToastProvider, ToastTitle, ToastViewport, useToastManager } from '@mobtranslate/ui';
type Variant = 'default' | 'success' | 'warning' | 'error';
interface LocalToastData { variant: Variant; undo?: () => void }
function NotificationFlow({single,onSingleChange}:{single:boolean;onSingleChange:(value:boolean)=>void}) {
const manager = useToastManager();
const [variant, setVariant] = useState<Variant>('default');
const [timed, setTimed] = useState(false);
const [exists, setExists] = useState(true);
const [outcome, setOutcome] = useState('Local example draft is present.');
const [feedback,setFeedback]=useState('No ordinary notification yet.');
const sequence = useRef(0);
const latest = useRef<string | null>(null);
const pending = useRef<{ resolve: (value: string) => void; reject: (error: Error) => void } | null>(null);
const [inFlight, setInFlight] = useState(false);
const startPromise = useRef<HTMLElement>(null);
return <div data-toast-lab className="space-y-4">
<p>These are real provider-managed notifications for local examples. The viewport is in the document so it cannot cover reading content.</p>
<label className="block">Notification variant <select className="mt-input" value={variant} onChange={event => setVariant(event.target.value as Variant)}>{(['default', 'success', 'warning', 'error'] as const).map(item => <option key={item}>{item}</option>)}</select></label>
<label className="flex gap-3 items-center min-h-11"><input type="checkbox" checked={timed} onChange={event => setTimed(event.target.checked)} />Expire ordinary examples after 5 seconds</label>
<label className="flex flex-wrap gap-3 items-center min-h-11"><input data-toast-delivery type="checkbox" checked={single} disabled={manager.toasts.length>0} aria-describedby="toast-delivery-help" onChange={event=>onSingleChange(event.target.checked)}/>One visible notification</label>
<p id="toast-delivery-help">Dismiss notifications before changing delivery. Existing task state stays retained.</p>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={() => { const title=`Local notification ${++sequence.current}`;setFeedback(`${title}. A local example outcome; no request was sent.`);latest.current = manager.add({ title, description: 'A local example outcome; no request was sent.', timeout: timed ? 5000 : 0, data: { variant } satisfies LocalToastData }); }}>Add local notification</Button>
<Button type="button" variant="outline" onClick={() => { if (latest.current&&manager.toasts.some(item=>item.id===latest.current&&item.transitionStatus!=='ending')) {manager.update(latest.current, { title: 'Local notification updated', description: 'The same notification ID was updated.' });setFeedback('Local notification updated. The same notification ID was updated.');} }}>Update latest notification</Button>
<Button type="button" variant="secondary" aria-disabled={!exists || undefined} onClick={() => {
if (!exists) return;
setExists(false); setOutcome('Local example draft removed. Restore is available here or in its notification.');
const id: string = manager.add({ title: 'Local draft removed', description: 'Undo restores only this local example.', timeout: 0, data: {
variant: 'default', undo: () => { setExists(true); setOutcome('Local example draft restored.'); manager.close(id); },
} satisfies LocalToastData });
}}>Remove example draft</Button>
<Button type="button" variant="outline" aria-disabled={exists || undefined} onClick={() => { if (!exists) { setExists(true); setOutcome('Local example draft restored from the persistent recovery action.'); } }}>Restore example draft</Button>
</div>
<div className="flex flex-wrap gap-3">
<Button ref={startPromise} type="button" variant="secondary" aria-disabled={inFlight || undefined} onClick={() => {
if (inFlight) return;
setInFlight(true);
const task = new Promise<string>((resolve, reject) => { pending.current = { resolve, reject }; });
void manager.promise(task, { loading: { title: 'Local task pending', timeout: 0 }, success: { title: 'Local task complete', description: 'Only the controlled local promise completed.', timeout: 0 }, error: { title: 'Local task failed', description: 'No request was sent; retry the local example.', timeout: 0 } }).catch(() => undefined);
}}>Start local promise</Button>
<Button type="button" variant="outline" disabled={!inFlight} onClick={() => { pending.current?.resolve('local result'); pending.current = null; setInFlight(false); startPromise.current?.focus(); }}>Resolve local promise</Button>
<Button type="button" variant="ghost" disabled={!inFlight} onClick={() => { pending.current?.reject(new Error('Simulated failure')); pending.current = null; setInFlight(false); startPromise.current?.focus(); }}>Reject local promise</Button>
</div>
<p data-notification-feedback>{feedback}</p>
<p role="status" aria-atomic="true">{outcome}</p>
<ToastViewport style={{ position: 'relative', inset: 'auto', width: '100%', maxHeight: '24rem' }} aria-label="Local example notifications">
{manager.toasts.map(item => {
const data = item.data as LocalToastData | undefined;
return <Toast key={item.id} toast={item} variant={data?.variant} swipeDirection={[]}>
<ToastContent><ToastTitle>{item.title}</ToastTitle><ToastDescription>{item.description}</ToastDescription>
{data?.undo && <ToastAction onClick={data.undo}>Undo local removal</ToastAction>}
<ToastClose aria-label={`Dismiss ${typeof item.title === 'string' ? item.title : 'local notification'}`} />
</ToastContent>
</Toast>;
})}
</ToastViewport>
<p className="text-sm">Provider limit: {single?1:3}. The one-visible mode uses the provider’s limit; it does not promise first-in-first-out delivery. Ordinary feedback remains above after expiry. Undo has no expiry and a separate persistent restore control. F6 enters the viewport; Shift+Tab returns from the viewport to the task. Escape dismisses a focused notification. Verify your actual browser and screen reader before release.</p>
</div>;
}
export function ToastCommunicationExample() {
const [single,setSingle]=useState(false);
return <ToastProvider timeout={0} limit={single?1:3}><NotificationFlow single={single} onSingleChange={setSingle}/></ToastProvider>;
}
Provider API
| Prop | Default | Contract |
|---|---|---|
| timeout | 5000 ms | Default ordinary expiry; 0 prevents automatic dismissal. |
| limit | 3 | Older notifications can become limited/inert; styles hide them from the visible stack. |
| toastManager | Absent | Instance from createToastManager for calls outside component context. |
| children | ReactNode | Triggers and renderer share the provider context. |
Manager API
| Method / object field | Contract |
|---|---|
| toasts | Reactive objects including id, title, description, data and transition/limited state. |
| add(options) | Returns an ID; title/description, timeout, priority, type, data, actionProps, callbacks and optional id are manager fields. |
| update(id, options) | Update an existing notification; do not assume a removed ID is a new add. |
| close(id) | Begin dismissal; final removal follows the transition lifecycle. |
| promise(promise, states) | loading/success/error strings or option objects; handle rejection in the caller. |
| priority | low (polite) by default; high requests urgent treatment. Color does not set priority. |
| timeout | Per-object override; 0 remains until dismissed or otherwise displaced. |
| onClose / onRemove | Distinct close initiation and post-animation removal callbacks. |
| data | Application-owned typed payload; renderer may select a variant or action. |
| createToastManager() | Same add/update/close/promise methods; reactive toasts still come from the provider hook. |
Root and parts API
Toast takes toast (required object), variant=default|success|error|warning (default), swipeDirection (primitive default down/right), className/string-or-state-callback, render, native root props and div ref. It does not take top-level title, description or duration shortcuts. The lab disables swipe to keep its in-flow document example predictable.
Title and Description connect their parts through the primitive context. Action and Close accept installed native-button/render props and button refs. Close supplies Dismiss notification unless aria-label is overridden; custom child text is retained. Content and Portal are direct primitive exports. Read the pinned 1.0.0-rc.0 source before relying on newer upstream options.
Behavior, keyboard and timing
- F6 enters the primitive viewport; Shift+Tab from the viewport returns to the prior task control. Escape on a focused notification dismisses it. Test focus return and the next enabled destination after a focused notification closes.
- No modal trap: keep reading and task controls available. Do not steal focus for routine arrivals.
- Primitive timers pause for hover/focus and window focus changes; verify the actual timer paths. Timeout=0 is useful for essential local recovery examples.
- Do not announce each change urgently. Choose priority according to the task, not the border color.
- Default fixed placement must reserve reading/focus clearance, safe-area space and other overlays. Test RTL and on-screen keyboards in the consuming layout.
- Use reduced motion and forced colors. A completed exit animation removes the object; hidden limited items are not proof that data was discarded.
Failure cases
- A raw div simulation advertised as the exported notification primitive.
- Documentation for nonexistent top-level duration/title props.
- An unlabeled icon close, duplicate active IDs or an unresolved promise with an unhandled rejection.
- The only undo action expiring or being displaced by a new notification.
- A fixed viewport wider than the mobile screen, covering source text or focused inputs.
- A toast saying Saved before the actual persistence outcome is verified.
Verification and release scope
- Operate the checked example with keyboard and pointer; inspect the actual name, role, relationships and focus destination.
- Check both themes, narrow reflow, long labels, reduced motion and forced colors. Text contrast and meaningful graphic contrast are separate checks.
- Test the relevant waiting, completion, failure, cancellation and recovery paths. A local simulation is not a verified backend result.
- Use a named screen-reader/browser pair to verify spoken output. DOM attributes and automated passes do not prove the announcement.
- Retain source, review and permission evidence when applying these controls to a real language workflow. Color and motion cannot establish authority.