Skip to content

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.

A new version is available
wayra-ui 0.6.0 is published.
Changes saved
The HOSPIMED template was published.
Certificate expiring
vixlum.com lapses in 14 days.
Could not connect
Check your connection and try again.
toast('Draft kept'); // neutral, no icon, no data-color
toast.info('A new version is available');
toast.success('Changes saved');
toast.warning('Certificate expiring');
toast.error('Could not connect'); // role="alert" — interrupts
toast.loading('Uploading…'); // spinner, never closes on its own

Fire 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.

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 });
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.

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.

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

position
<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.

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.

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.

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-child rules on the dialog’s own rows keep matching. It inherits from the dialog there, not from where the Toaster is mounted.
  • The deck leaves a closing dialog on its beforetoggle event, 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 own transition: 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.

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.

  • The container is a labelled region with aria-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.
  • error cards are role="alert" and interrupt; every other card is role="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-motion the movement goes away and the clock keeps running: the deck fades instead of sliding.