Toast
A toast reports something that already happened, in the corner, without stopping the page. The cards stack into a deck, fan out when you point at them, and pause their own countdown while you read — all three in CSS. JavaScript only inserts the card, measures its height and writes its place in the deck.
- Stable
- React 19
- CSS clock
- 6 positions
Mount one Toaster at the root of the app, then call toast from anywhere —
an event handler, a fetch, a store. The component owns no props about content;
the queue is a module singleton.
import { Toaster, toast } from 'wayra-ui';
export function App() { return ( <> <YourRoutes /> <Toaster label="Notifications" dismissLabel="Dismiss" /> </> );}
toast.success('Changes saved', { description: 'The template was published.' });label and dismissLabel are required: they are the only two strings the
library would otherwise hard-code in English, and a toast is announced text.
The four kinds, plus neutral
Section titled “The four kinds, plus neutral”toast('Draft kept'); // neutral, no icon, no data-colortoast.info('A new version is available');toast.success('Changes saved');toast.warning('Certificate expiring');toast.error('Could not connect'); // role="alert" — interruptstoast.loading('Uploading…'); // spinner, never closes on its ownFire them for real. The deck lands in the corner of the window, not inside the frame — a toast reports from outside whatever you are looking at, so the frame below holds only the buttons.
the deck sits in the bottom-end corner — point at it to fan the cards out and pause every clock; a loading card has no clock at all
Code
import { Button, Toaster, toast } from "wayra-ui";import { usePosition } from "./deck";
/* The only `Toaster` on this page. Everything else that fires a toast — the * buttons below, and the position picker further down the page — reaches it * through the module singleton, never by mounting a deck of its own. * * The deck is fixed to the viewport corner rather than to this frame, which is * what a real toast does: it reports something that already happened, from * outside whatever the reader is looking at. */
// A stand-in for the request a real handler would await.function publish(): Promise<{ name: string }> { return new Promise((resolve) => { setTimeout(() => resolve({ name: "HOSPIMED" }), 1400); });}
function refuse(): Promise<never> { return new Promise((_resolve, reject) => { setTimeout(() => reject(new Error("el servidor no responde")), 1400); });}
export function KindsDemo() { const position = usePosition();
return ( <div className="hu-demo-col"> <div className="hu-demo-row"> <Button variant="outline" color="neutral" onClick={() => toast("Draft kept")} > toast </Button> <Button variant="outline" color="info" onClick={() => toast.info("A new version is available", { description: "wayra-ui 0.6.0 is published.", }) } > info </Button> <Button variant="outline" color="success" onClick={() => toast.success("Changes saved", { description: "The HOSPIMED template was published.", }) } > success </Button> <Button variant="outline" color="warning" onClick={() => toast.warning("Certificate expiring", { description: "vixlum.com lapses in 14 days.", }) } > warning </Button> <Button variant="outline" color="error" onClick={() => toast.error("Could not connect", { description: "Check your connection and try again.", }) } > error </Button> <Button variant="outline" color="neutral" onClick={() => toast.loading("Uploading the study…")} > loading </Button> </div>
<div className="hu-demo-row"> <Button size="sm" variant="outline" onClick={() => toast.success("Message archived", { description: "Recover it from Archived.", action: { label: "Undo", onClick: () => toast("Message restored"), }, duration: 7000, }) } > with an action </Button> <Button size="sm" variant="outline" onClick={() => toast.promise(publish(), { loading: "Publishing the template…", success: (data) => `The ${data.name} template was published`, error: "Could not publish the template", }) } > promise </Button> <Button size="sm" variant="outline" onClick={() => toast .promise(refuse(), { loading: "Publishing the template…", success: "The template was published", error: (reason) => `Could not publish: ${String(reason)}`, }) // The card reports the failure; handling the rejection is still // the caller's job, and without this the page logs it unhandled. .catch(() => undefined) } > promise that fails </Button> <Button size="sm" variant="ghost" color="neutral" onClick={() => toast.dismiss()} > dismiss all </Button> </div>
<p className="hu-readout"> the deck sits in the {position} corner — point at it to fan the cards out and pause every clock; a loading card has no clock at all </p>
<Toaster label="Notifications" dismissLabel="Dismiss" position={position} /> </div> );}loading never closes on its own — that is what dismiss all is for.
The colour is the meaning and it drives two things at once: the accent on the
icon and the progress bar, and the announcement. error renders
role="alert" so a screen reader interrupts; everything else is
role="status" and waits for a pause. Either way the card lands inside the
deck, which is itself a polite live region.
The spinner a wait draws
Section titled “The spinner a wait draws”A loading card draws Loading, and loadingVariant
picks which of its twenty-two animations — any LoadingVariant that component
accepts. Set it per call, or once on the Toaster for the whole deck.
toast.loading('Uploading…', { loadingVariant: 'ring' });
<Toaster label="Notifications" dismissLabel="Dismiss" loadingVariant="dots" />The call wins over the deck, and toast.promise passes it through to its
loading stage. The indicator is sized to the icon bubble rather than to
Loading’s own step ladder, so every variant lands in the same slot, the six
that are wider than they are tall included. Passing icon replaces it
altogether.
Duration, and the clock that pauses itself
Section titled “Duration, and the clock that pauses itself”error gets six seconds, every other kind four, loading none at all. Pass
duration per toast, or a default for the whole deck on the Toaster;
duration: 0 keeps a card until something dismisses it.
toast.error('Could not connect', { duration: 8000 });toast.warning('Needs your attention', { duration: 0 });One action, and undo
Section titled “One action, and undo”toast.success('Message archived', { action: { label: 'Undo', onClick: restore }, duration: 7000,});Acting on a toast dismisses it: the reason it was on screen is gone. The action button sits in the body under the description, so the card grows and the deck re-measures on its own. The with an action button above fires exactly this call.
Promises
Section titled “Promises”toast.promise(upload(file), { loading: 'Uploading…', success: (data) => `Uploaded ${data.name}`, error: (err) => `Upload failed: ${String(err)}`,});The spinner card becomes the result card in place — same id, same position in
the deck, no second entrance. aria-atomic makes a screen reader re-read the
whole card when it changes.
The result does not cut in: the icon pops and the rows rise again, success and
failure alike, and a failure adds the wrong-password shake on the whole card.
The hook is one presence attribute, data-resolved, set on any card whose
loading state was replaced by a result — toast.promise and the same-id idiom
alike — so hand-written markup gets the same replay by writing it. The
promise and promise that fails buttons above run both halves.
Position
Section titled “Position”Pick a corner and fire one. It is the same deck the buttons above use — one
Toaster per application — so the attribute is rewritten on the live
container rather than a second deck being mounted per corner.
<Toaster position="bottom-end" /> — the deck is fixed to the viewport, not to this frame
<Toaster label="Notifications" dismissLabel="Dismiss" position="top-center" />bottom-end is the default, and the component writes it as data-position
like any other value; a hand-written deck that leaves the attribute off lands
in the same corner. The block half decides which way the cards enter
and stack; the inline half is logical, so start and end follow the writing
direction. One custom property flips the whole deck — stacking, entrance and
all three exits — so there is no second set of keyframes for the top edge.
The countdown bar shrinks toward the inline start, so it follows the writing
direction too. :dir() only arrived in Chrome 120, so the bar also reads a
dir="rtl" attribute on the card or any ancestor, which is all it has below
that version. The one case it misses there is an LTR island inside a
right-to-left page.
The deck
Section titled “The deck”Cards do not queue in a column: they stack. The newest sits in front, the ones behind peek out and shrink a little, and pointing at the deck fans them out to their real heights.
max is a visible cap, not a queue limit: cards past it stay in the store and
stay announced, they are just hidden until the ones in front leave. It
defaults to 3.
Where the engine has the Popover API the deck is a popover="manual": the top
layer, over the page with no z-index, and never light-dismissed or closed by
Escape. Nothing is portalled to document.body — the deck renders where the
Toaster is mounted — but the cards reach it through a React portal whose
container is the deck itself, so their clicks keep working after the deck
moves into a dialog outside the React root (below), and
the server can render the region empty with nothing to mismatch.
Three ways out, three gestures
Section titled “Three ways out, three gestures”| How it left | What it looks like |
|---|---|
| The clock ran out | sinks back into the deck and fades away |
| You closed it | leaves the way it came in, fast |
| You swiped it | keeps going from where you let go, at the speed of your hand |
All three shrink the gap while the card leaves, so the deck closes up in the same movement instead of jumping afterwards. A new card settles into place without overshooting it, and every entrance and exit moves only opacity, position and scale, so none of them repaints the card.
A swipe can start anywhere on the card but its buttons, which keep their own press. It takes the main button of the first pointer only, and follows that pointer alone: a right-button drag, a second finger or a pen joining in moves nothing. A link in the message still takes its click: the drag holds the pointer on whatever was pressed, so a press that does not travel ends where it began.
The card follows only once the press has travelled about 10px, and only if it travels more sideways than up or down. A click, a small wobble or a vertical scroll never moves the card, and neither does a press that is selecting text in the message. A vertical press is left to the page for good.
When you let go, the card leaves if it is more than 90px from where it started, or if you flick it at 0.5px/ms or faster, even a short way. A flick back toward the start keeps the card, however far it went. The speed is read from the last 100ms of the drag, so a drag that slows to a stop is judged by its distance. Otherwise the card goes back to its place. A flicked card keeps the speed of your hand: its exit starts at that speed and slows down from there, and lasts no less than 120ms and no more than the plain exit. So only a fast flick shortens it.
With a modal open
Section titled “With a modal open”A modal <dialog> makes the rest of the page inert and paints its scrim over
it: anything left outside can be neither seen nor clicked, and a screen reader
no longer hears it. So while a modal is open the deck moves inside the
top-most one and is raised above it. When that one closes, the deck goes back
to the modal below, or to where the Toaster is mounted. Any showModal()
counts, not only Modal and Drawer.
<Modal open={open} onClose={close} title="Archive the message" closeLabel="Close"> <Button onClick={() => toast.success('Message archived', { action: { label: 'Undo', onClick: restore }, }) } > Archive </Button></Modal>The deck is the same element the whole time, so nothing restarts: a card keeps its place in the deck and the time it has left. The limits:
- Below the popover tier the deck is a plain fixed element. It stays where it is mounted, and an open modal covers it.
- Inside a dialog the deck is one of the dialog’s children. It goes just
before the last one, or last when the dialog has only one child, so its
buttons come late in the tab order and
:first-child/:last-childrules on the dialog’s own rows keep matching. It inherits from the dialog there, not from where theToasteris mounted. - The deck leaves a closing dialog on its
beforetoggleevent, before the dialog is hidden. An engine that does not fire that event on<dialog>moves the deck a moment later, and if that dialog has no exit transition (a consumer’s owntransition: none, for one) the clocks of the cards inside restart. They also restart when a plain<dialog>is removed from the page while it is open; the deck still comes back.
Styling API
Section titled “Styling API”The class plus the data-* attributes plus five custom properties is the
whole contract — the deck above is hand-written markup with no React in it.
A page that writes the numbers itself gets the same stacking:
<div class="wayra-toaster" role="region" aria-label="Notifications" aria-live="polite" style="--wayra-toaster-count: 2; --wayra-toaster-front-h: 84px; --wayra-toaster-total-h: 178px"> <div class="wayra-toast" role="status" aria-atomic="true" data-color="success" style="--wayra-toast-index: 0; --wayra-toast-before: 0px"> … </div></div>--wayra-toast-index counts from the front (0 is the newest) and
--wayra-toast-before is the total height of the cards ahead of it. Those
two, plus the three on the container, are everything the JavaScript writes.
With unstyled none of the library’s rules reach the deck, and so none of its
animations run: no CSS clock, and no exit whose end removes a card. The
component then keeps each card’s time itself, paused while the deck is hovered
or holds the focus, the same way the stylesheet pauses its own clock, and a
dismissed card leaves at once. If your own CSS gives the card an exit
animation whose name ends in -out, the card stays until that animation ends.
Accessibility
Section titled “Accessibility”- The container is a labelled
regionwitharia-live="polite", in the DOM before any toast arrives. A card is announced because it is added to a live region the screen reader already knows about. A region inserted together with its text is not reliably announced, which is why each card cannot carry the announcement on its own. Hand-written markup needs the same attribute on the container. errorcards arerole="alert"and interrupt; every other card isrole="status"and waits for a pause. The region itself is not atomic, so a new card is read by itself, not together with the cards already there.- While a modal is open the region is inside it. Left outside, it would be
out of the accessibility tree, and nothing in it could be heard. It is the
same node before, during and after, never a new one. Where the engine has
moveBefore(Chrome 133+) it moves without leaving the page. Elsewhere it is taken out and inserted again, which a screen reader may take for a new region: a toast fired at the very moment a modal opens or closes may go unannounced there. - Icons and the progress bar are
aria-hidden; only the text is announced. - Tabbing into the deck fans it out and pauses the clocks, so a keyboard user gets the same reading time a pointer user does.
- The close button clears the 24px target floor while it is reachable, and on a touch device it never hides at all.
- With
prefers-reduced-motionthe movement goes away and the clock keeps running: the deck fades instead of sliding.