NbToaster is the host: one region, mounted once, that renders the shared toast queue. useToast() is how everything else talks to it. Between them they own the parts of a notification that are not about how one message looks: where the stack sits, how many are on screen, when each one leaves, what pauses the clock, what is announced and what happens to focus when a message the user was reading disappears.
NbToast renders a single message and stays exactly what it was. NbToaster decides when it appears and when it goes.
Why this is library code now
The 12-application audit found three products with a hand-built host over NbToast, because the toast page used to state as policy that the library shipped no container. Each one lost something different:
- one had no auto-dismiss at all, so successes accumulated for the life of the route,
- one wrote
var(--nb-z-toast), a name that does not exist, and silently ran on the fallback rather than--nb-zindex-toast, - one dropped
role="alert", the variant icon, the action and pause-on-hover.
None of those are careless. They are what a container costs to get right. The page that caused them now points here.
When to toast
A toast is news that expires. If the message would still be true and still be worth reading five minutes from now, it is not a toast.
| Use | When |
|---|---|
NbToaster | Something just finished, anywhere on the page, and the news self-clears. |
NbBanner | Something is true about this page or task and stays on screen. |
NbMessage | One field has something to say about its own value. |
NbConfirm | Nothing else may proceed until the user answers. |
| Notification centre | The news outlives the moment and has to be findable later. |
Do not toast a validation error: it belongs next to the field that caused it. Do not toast something the user can already see happening. Do not toast twice for one action.
The row above is the surface. Which words a state is allowed to be called, and which component owns "what is true about this thing right now", are settled once in Status indicators rather than restated here. A toast is the only entry in that vocabulary that is a change rather than a state, which is exactly why it may expire and the others may not.
Anatomy
region role="region", named, fixed to one viewport edge, click-through
└── stack newest nearest the anchored edge, capped at max
└── item one queue record
├── icon variant
├── severity word visually hidden, read first
├── title optional
├── message required
├── action optional, at most one
├── close always present, labelled
└── countdown hairline, only when the toast expires
announcer two live regions, mounted empty for the life of the hostThe announcer is not part of the stack and never moves. It exists so that the region a screen reader is watching is older than the message written into it.
Mounting it
One NbToaster per application, at the root, outside the router view so it survives navigation. It teleports to <body>, so it does not matter where in the tree you put it.
<!-- App.vue -->
<script setup lang="ts">
import { NbToaster } from '@nubisco/ui'
</script>
<template>
<RouterView />
<NbToaster />
</template>NbToaster is also registered globally by the NubiscoUI plugin, so <NbToaster /> works with no import if you install the plugin.
One host, not two. Two NbToasters bound to the same queue render every record twice and announce it twice through two announcers, and both write their max prop into the shared queue. The host warns about this in development. A second host is fine as long as it has its own queue from createToastQueue(), which is what the demo further down this page does.
Then raise toasts from anywhere: a component, a store, a route guard, an interceptor. useToast() returns the same queue every time it is called, so nothing has to be passed down.
import { useToast } from '@nubisco/ui'
const toast = useToast()
toast.success('Draft saved')
toast.error('Could not reach the server')
toast.info('A new version is available', {
cta: { label: 'Reload', action: () => window.location.reload() },
})Live demo
The stack below is the real component. It is teleported into the framed area rather than to <body>, so it stays inside the page: the toaster is position: fixed, and a transform on an ancestor is what makes fixed positioning resolve against that ancestor instead of the viewport. In an application you leave to alone and the stack anchors to the viewport corner.
Hover it, tab into it, press Alt + T, and watch the countdown bars stop. "Route change" runs what a router guard would run: everything goes except the retained toast.
How long a toast stays
Duration follows the variant unless you say otherwise. The scale is reading time, not importance.
| Variant | Default | Why |
|---|---|---|
success | 4000 ms | Three words the user was expecting. |
info | 6000 ms | Something they did not ask about. |
warning | 8000 ms | A sentence they were not expecting and have to finish. |
error | 0 | Never auto-dismisses. "It failed" is the start of a task, not the end. |
Two rules sit on top of the table:
duration: 0means "until dismissed" on any variant, and any explicit number wins over the default.- A toast that carries an action does not auto-dismiss unless you give it a duration. The only route to that action for a keyboard user is to reach it, and an info toast's six seconds are gone before the tab ring arrives.
toast.warning('You are in read-only mode', { duration: 0 })
toast.success('Copied', { duration: 1500 })
toast.info('Comment deleted', { cta: undoAction }) // persistent, automatically
toast.info('Comment deleted', { cta: undoAction, duration: 8000 }) // unless you say soThe clock is shared
The whole region pauses, not the toast under the pointer. Pausing only the hovered one lets the two below it expire while they are being read, which is the bug that makes people distrust toasts.
The clock is held while:
- the pointer is over the stack,
- focus is anywhere inside it,
- the browser tab is hidden.
It resumes from the time that was left, never from the beginning. That is why every expiring toast carries a hairline countdown at its bottom edge: it is the only visible statement of how long is left, and it stops where the clock stops.
The cap and what overflows
Three visible at once by default (the queue's max). Beyond that, extras wait in the queue and slide in as slots free up, in the order they were raised. Nothing is dropped on the floor.
The one exception is a screen where nothing can expire, which in practice means every visible slot is held by a persistent toast. Waiting for a free slot would silence every later message for the rest of the session, so in that case, and only that case, the oldest visible toast gives up its slot to the newest, and its onDismiss is called with 'replaced'.
Both branches, in order:
const toast = useToast()
// One slot is held by something that will expire, so the fourth waits.
toast.error('Upload 1 failed') // persistent
toast.error('Upload 2 failed') // persistent
toast.success('Upload 3 finished') // expires in 4s
toast.success('Upload 4 finished') // pending, until slot 3 frees itself// Nothing on screen can expire, so the newest message would never arrive.
toast.error('Upload 1 failed') // persistent
toast.error('Upload 2 failed') // persistent
toast.error('Upload 3 failed') // persistent
toast.success('Upload 4 finished') // shown at once; 'Upload 1 failed'
// ends with reason 'replaced'That second case is a deliberate trade and it is the only place the queue takes something off screen that the user has not read. If a message must not be displaced, that is what retain: true is not for: retain survives navigation, not overflow. Raise fewer persistent errors instead, or collapse the batch into one toast with a cta that opens the full list.
Progress that becomes a result
push() and the four shorthands return a handle, so the code that raised a toast can keep talking about it. This is the save-in-progress case, and it is why applications end up with two toasts for one action when the host has no handles.
const saving = toast.info('Saving changes...', { duration: 0 })
try {
await save()
saving.update({ variant: 'success', message: 'Changes saved' })
} catch {
saving.update({
variant: 'error',
title: 'Save failed',
message: 'Your changes are still here. Try again.',
})
}The toast keeps its slot and its position, so the stack does not jump under the cursor. The countdown restarts, because the words changed and nobody has read the new ones yet. A variant passed without a duration adopts that variant's default, which is exactly what turns the persistent progress toast into a success that clears itself and an error that does not.
Undo, and knowing when a toast is over
onDismiss is called once per pushed message, with the reason it ended: user, timeout, action, replaced, navigated or cleared. It is what makes the canonical "Deleted. [Undo]" pattern writable, because the pattern needs a moment to commit the delete and a way to know the reader took it back instead.
function deleteComment(comment: { id: string }) {
let undone = false
hide(comment) // optimistic
toast.push({
message: 'Comment deleted',
cta: {
label: 'Undo',
action: () => {
undone = true
restore(comment)
},
},
onDismiss: () => {
if (!undone) void api.deleteComment(comment.id)
},
})
}An update() is not an end of life, so a progress toast that becomes a result calls onDismiss once, at the end, with the result's reason. For the same reason update() cannot change the callback: TToastPatch omits it, and passing it anyway is ignored with a warning outside production. The callback belongs to the message that was pushed, and swapping it halfway would either drop a commit or run one twice. When the message really is a new one, push it again (see below).
A callback that throws is logged and swallowed: the other toasts on screen still have to expire.
Repeats, and one slot handed from message to message
Give a toast a key and pushing it again takes over the toast already on screen instead of adding a second one. A double-clicked Save produces one "Saved".
toast.push({ key: 'save', variant: 'success', message: 'Draft saved' })Use it for anything that can fire in a burst: autosave, a websocket reconnect, per-row bulk actions.
Two rules make that safe to combine with the onDismiss pattern above, and both are worth knowing before you reach for a key.
The second push replaces the message, it does not merge into it. Only the id, the slot and the position survive; every field the new call leaves out goes back to its default. So this is a plain info toast, not the earlier error wearing a new sentence:
toast.push({
key: 'row',
variant: 'error',
title: 'Delete failed',
message: 'Row 1 is still there',
})
toast.push({ key: 'row', message: 'Row 2 deleted' }) // info, no titleThe message being displaced ends first, with reason replaced. It may be holding an optimistic delete that nothing else will ever commit, so the queue runs its onDismiss before the new message takes the slot. Deleting two rows quickly commits row 1 and then row 2, in that order:
function deleteRow(row: { id: string; name: string }) {
let undone = false
hide(row)
toast.push({
key: 'delete-row', // one "deleted" toast, however fast they click
message: `${row.name} deleted`,
cta: {
label: 'Undo',
action: () => {
undone = true
restore(row)
},
},
onDismiss: () => {
if (!undone) void api.deleteRow(row.id)
},
})
}Pushing the same callback under the same key twice is not a displacement, so an autosave that fires twice does not commit twice.
A toast and the route it came from
A toast is scoped to a moment, and the moment usually ends with the route. "Draft saved" still on screen two pages later is news about a screen the user has left, and its Undo acts on a record they can no longer see. Nothing in the library can tell when that has happened: it has no router dependency and cannot know whether your tab switch is a navigation. So the queue provides the operation and the application says when.
// router.ts
router.afterEach(() => toast.dismissTransient())dismissTransient() clears everything that has not asked to survive, with reason 'navigated', and lets pending toasts through as usual.
Read this before you paste that line. afterEach runs after the navigation it belongs to, so it also clears anything raised during that navigation, before it was ever painted. The common case is a guard:
router.beforeEach((to) => {
if (!session.valid) {
toast.error('Your session expired. Please sign in again.')
return { name: 'sign-in' } // afterEach then fires, and eats the toast
}
})The toast ends with reason 'navigated' without being seen. Guard toasts, and anything else raised on the way to a new route, have to say so:
toast.error('Your session expired. Please sign in again.', { retain: true })Mark the other exceptions the same way, the news that is still true wherever the user ends up:
toast.info('Export ready', {
retain: true,
cta: { label: 'Download', action: download },
})dismissAll() is the harder version: it clears retained toasts too. Use it when nothing queued is true any more, which is sign-out, a tenant switch, or a session that has expired.
If you would rather keep toasts across navigation, do nothing: that is the default, and it is a defensible choice for a product whose routes are panels of one workspace. What is not defensible is never deciding, which is what the audited applications did.
Placement and size
bottom-end by default: the bottom inline end of the viewport, clear of the shell top bar where the actions that raise most toasts live.
The newest toast lands nearest the anchored edge under every placement, so the arriving message appears in the same place every time and the older ones are pushed away from it. That takes two halves, and an earlier revision of this component only had one of them: the records are rendered newest first (so browse mode and Tab reach the message that was just announced first), and the flow is reversed for the bottom anchors only (so the first item is laid out from the bottom edge rather than the top). Both are asserted, per placement.
<template>
<NbToaster placement="top-end" :max="2" />
</template>Placements are logical: -start and -end follow the document direction rather than left and right.
On a viewport at or above the md breakpoint (672px, the library's real scale) a toast is between 280 and 380 pixels wide, and never wider than the space it has. Below that breakpoint the range does not apply: the toast drops its minimum to zero and its maximum to the full width, the inset halves, and the stack spans the viewport, because 24 pixels of padding on each side of a 280 pixel toast does not fit a 320 pixel phone. On a 320 pixel phone a toast is therefore about 296 pixels wide, which is narrower than the stated minimum on purpose. Long unbroken content (an id, a URL, a file name with no spaces) wraps rather than being clipped.
Keep the message to one or two lines. If it needs more, it is not a toast: put it on the page, or open a confirm.
Rendering something else
#toast is a scoped slot over one queue record. Use it when a product needs a body the props cannot express (a thumbnail, two actions, a live percentage) and still wants the queue, the clock, the cap, the announcement and the focus handling.
<template>
<NbToaster>
<template #toast="{ toast, dismiss, paused }">
<MyBrandedToast
:record="toast"
:paused="paused"
@close="dismiss()"
@undo="dismiss('action')"
/>
</template>
</NbToaster>
</template>| Slot prop | Type | Description |
|---|---|---|
toast | IToastRecord | The record: id, variant, title, message, cta, duration, cycle. |
paused | boolean | Whether the shared clock is currently held. |
dismiss | (reason?: TToastDismissReason) => void | Remove this toast, with focus handled as usual. Pass 'action' when your own button did the thing; left unsaid it reports 'user'. |
action | () => void | Mark this toast as actioned without removing it. The dismissal that follows then reports 'action' on its own. |
reason is not decoration. It is the whole of the undo contract: onDismiss fires once, and 'action' means the user pressed Undo while 'user' means they closed the toast and the delete should commit. A slotted body that renders its own action button and calls a bare dismiss() reports 'user' for both, which silently commits every undo. Either name the reason, or call action() first.
<template #toast="{ toast, dismiss }">
<div class="my-toast">
{{ toast.message }}
<button @click="dismiss('action')">Undo</button>
<button @click="dismiss()">Close</button>
</div>
</template>What the host still does for a slotted body: the queue, the shared clock, the cap, pause on hover and focus, the announcement, Escape, the hotkey and the roving focus on dismissal. It finds the controls to focus by looking for focusable elements inside its own item wrapper, so the one thing your body has to keep is that it contains something focusable, usually a close button. A body with no focusable element at all renders and announces correctly, but there is nothing for Alt + T to land on, so the key is left to the page rather than swallowed.
What becomes yours: the visually hidden severity word, the accessible name of the close control, and the countdown bar. Read the Accessibility tab before you do this.
Writing the message
Say what happened, in the past tense, without a full stop. Put anything the user has to do in a title and keep the message to the recovery.
| Do | Don't |
|---|---|
toast.success('Draft saved') | toast.success('Success!') |
toast.error('Could not reach the server', { title: 'Save failed' }) | toast.error('Something went wrong. Please try again.') |
cta: { label: 'Retry', ... } | cta: { label: 'OK', ... } |
| One toast per action | One toast for the request and another for the response |
Tense, capitalisation and the sentence shapes are the fleet's, not this component's: Writing style is the source, and this section only adds what is peculiar to a message that removes itself.
Label the action with its verb: Retry, Reload, Undo. The audit found Cancel, Done, Close and Dismiss used interchangeably across 12 products, so a toast's action should never be one of them: the close button is the exit, and a second control that also means "go away" is a coin toss for anyone reading the stack through a screen reader.
Not in English
Two strings are generated by the library rather than by your call: the visually hidden severity word and the close button's name. State them once per queue.
import { createToastQueue } from '@nubisco/ui'
export const toast = createToastQueue({
statusLabels: {
success: 'Correcto',
error: 'Error',
warning: 'Aviso',
info: 'Informacion',
},
closeLabel: 'Cerrar',
})<template>
<NbToaster
:queue="toast"
label="Notificaciones"
hotkey-hint="Pulse Alt mas T para abrirlo."
/>
</template>Replacing a hand-built host
If your application has its own toaster over NbToast, the move is mechanical:
- Delete the local
Toaster.vueand the localuseToast. Mount<NbToaster />in its place. - Rename the push call:
push({ variant: 'success', message })becomestoast.success(message).pushstill exists with the same option names. - Delete any local
z-indexrule. The host sets--nb-zindex-toast, which resolves above modals. If you had--nb-z-toast, that name never existed. - Anywhere you raised two toasts for one operation (a "saving" and then a "saved"), keep the handle from the first and
update()it.
What changed in NbToast
The API is backwards compatible: a bare <NbToast> still times itself, still pauses under the pointer, and still defaults to role="alert". role and closeLabel became real props with their old values as defaults, and managed, paused and cycle are additive.
One change is visible to consuming code, and it is the reason this section exists. Every NbToast now renders a visually hidden severity word before the title, so a screen reader hears "Error. Could not reach the server" rather than a sentence whose severity was only ever a colour and an aria-hidden icon. That changes the accessible text of every existing standalone toast, and with it wrapper.text() in your tests and any snapshot that covers one.
- If the word is wanted, which it usually is, update the assertion.
- If your title already carries it ("Save failed"), pass
status-label=""to suppress it and avoid "Error. Save failed. Could not reach the server". - Under
NbToasteryou do not need to do anything: the host passes the queue'sstatusLabels, and translates them with the rest of the interface.
The info variant's icon and left rule also move from the brand purple (--nb-c-primary) to --nb-c-info, the same token NbBanner uses, and the corner radius moves from a hardcoded 10px to --nb-radius-md.