Skip to content

Transient feedback fired from anywhere. One host at the app root, and a toast() that stacks, pauses on hover and dismisses itself.

import { BbToast } from 'bitboss-ui';

On this page

Use it for

A toast is a short notification about what just happened: "Changes saved", "Upload complete", "Copied to clipboard". It appears in a corner of the screen, stacks with its siblings, and disappears on its own after four seconds.

Use something else when

  • BbAlert, when the message describes the current state of the page and has to stay until someone deals with it
  • BbConfirm, when the user has to decide rather than be told

Never put information a user must act on only in a toast: it is gone before they look up.

Like BbConfirm, this is not a control you place. The app's one <BbHost /> renders every toast, takes no props and no space where you mount it, and everything you write goes through useToast().

Passthrough

Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.

One host, then a function

Mount <BbHost /> once, beside the router outlet, and toast() works from anywhere after that. The same host renders every confirm too.

vue
<template>
    <NuxtLayout>
        <NuxtPage />
    </NuxtLayout>
    <BbHost />
</template>

<script setup lang="ts">
import { BbHost } from 'bitboss-ui';
</script>

toast, update and dismiss all operate on one shared module-level stack, so every useToast() call in the app talks to the same host. With no host, messages wait in a queue and show once a host mounts. Nothing expires them in the meantime, because the dismiss timer lives inside the host. With two hosts, the second renders nothing until the first unmounts. A dev build warns about both.

A toast takes the pt scope and the theme tokens of the place its host is mounted, not of the code that fired it. An app whose areas sit under different named scopes mounts one host inside each area's layout, within its <BbPtScope>, and none at the root. Every page must then render inside one of those layouts. A page outside them, such as a login or error page, has no host at all, and its toasts wait in the queue.

Fire toasts from event handlers and settled requests, never from setup during server rendering: on the server there is nothing to render them into.

This site mounts its one <BbHost /> in app.vue, so the demos below mount nothing, and neither does a page of yours that calls toast().

What toast() takes

OptionDefaultWhat it does
titlenoneThe bold line; toast('…') sets only this
textnoneBody copy under the title
variant'default'success, info, warning, destructive or a registered name
iconthe variant'sThe leading icon, such as 'lucide:circle-check'; wins over the variant's
hideIconfalseHides the icon, the variant's or its own; the spinner still shows
loadingfalseA spinner in place of the icon; keeps the toast open
duration4000Milliseconds on screen, always above zero
persistentloadingNever leaves on its own
hideClosefalseHides the ×; with persistent, only when your code calls dismiss(id)
positionthe plugin's toastPositionOne of the six corners
actionsnoneButtons under the text
portal, datanoneA <BbToastPortal> name, or a component, that renders the whole body; data is what it renders
idgeneratedReuse one to patch that toast in place

useToast() returns toast, update(id, patch) and dismiss(id); dismiss() with no id clears every toast.

Communicate an outcome

toast('Changes saved.') is the whole shorthand. The object form adds a body, a tone and an icon.

A variant tints the icon and sets the announcement priority, nothing else. Without an icon, a success toast looks like a default one, so give each variant its icon once, in the plugin options (Variants and CSS hooks), instead of on every call. Dev builds warn when a built-in variant arrives without one.

warning and destructive announce assertively (role="alert"); every other variant is polite. Use the loud two sparingly, or they stop meaning anything.

duration defaults to 4000ms and must be greater than zero, or toast() throws. "Never dismiss" is persistent: true, not duration: 0. The call returns the message id, which the next two sections are about.

Coming from v2

The options object was rewritten: theme is variant, timeout is duration (its default fell from 6000ms to 4000ms), and showClose inverted into hideClose. They are call options, not template attributes, so no markup check sees them. A dev build warns once per call that still passes one and names the replacement; a production build drops the key.

Loading, then the result

Work that takes a moment gets one message, not two.

loading: true shows a spinner and pins the toast open. It implies persistent, so the countdown never starts and a swipe cannot lose it. Keep the id, and when the work settles update(id, …) morphs the same card in place and restarts the countdown. Pass loading: false in that update, or the spinner and the pinning stay.

update on an id that has already left the stack is a safe no-op. A slow request resolving after the user cleared the corner is not a crash. Each toast is announced whole, so the morph from loading to done is read out again.

A message that must stay until dismissed takes persistent: true. Add hideClose only when your own code will call dismiss(id): the two together, with nothing to dismiss the toast, trap it on screen forever.

One toast per concern

Pass your own id and toast() becomes an upsert.

0 revisions this session

If a toast with that id is still live, it is patched in place and its countdown restarts instead of a duplicate stacking behind it. Use a stable id for anything repeatable, such as autosave ticks, connection status or "copied to clipboard", so hammering the action never floods the corner.

Fields you omit keep their previous value; fields you pass overwrite, including the ones you can set back to undefined. It is the same mechanism update uses; the difference is that update never creates.

Buttons on a toast

An actions array renders buttons in a row under the text, and carries the undo pattern.

Saved replies

  • Thanks, we are on it
  • Your refund is on its way
  • Escalated to engineering

Commit the delete immediately, with no confirm dialog, and let the toast hold the escape hatch: the action's closure keeps the removed record, and if the toast expires the delete stands. This is the right trade when undoing is cheap and complete. When it is not, you want BbConfirm before the fact instead.

While a button's onClick runs it shows a spinner, its siblings are disabled and the countdown is held; the toast dismisses itself when the callback resolves. Two behaviours compose out of that: a rejected handler keeps the toast open and resumes the countdown, which is a retry, and a handler that calls update(id, …) takes ownership: the toast morphs in place with a fresh countdown rather than dismissing.

Each action is a small primary button unless you pass variant or size. One or two actions is the limit. A message that needs more, or that must not be missed, is a dialog.

When the body needs more than an icon, a title, text and buttons, register your own with <BbToastPortal> and fire a toast that names it.

Ingrid Halvorsen

Ingrid Halvorsen

Financial Analyst, Finance

The portal replaces the whole body, so icon, title, text and actions stop rendering and the portal owns its own buttons. The slot scope is { toast, data, close }, where data is the call's data. A component passed as portal gets the keys of data as its props, plus a close prop. Reach for it rarely.

Where toasts appear

Six corners: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right.

Each message resolves its corner in two steps: its own position, then the plugin's toastPosition (bottom-right out of the box). Messages in different corners form independent stacks. Pick one corner per product and stay there; a per-message position is for the rare control that lives somewhere else on the screen, like a copy button pinned to the top.

Everything else about the stack is automatic and worth not rebuilding: toasts pile up newest in front with only the front three visible, hovering or focusing the pile fans it out and pauses every timer, timers also pause while the browser tab is hidden and resume with the time remaining, and a toast can be swiped towards its nearest screen edge. Under 600px the stack goes full width.

The stacks are promoted into the browser's top layer (they stay in the DOM where the host sits), so they paint above dialogs, off-canvas panels and menus. When a modal opens, the region moves inside it: a modal makes everything outside itself inert, and a toast nobody can click is worse than no toast. When a non-modal panel claims a screen edge, the stack yields: a bottom sheet pushes bottom stacks up, a right panel pushes right stacks left, in step with the slide. Your own docked panels are ordinary page markup that the stack cannot see, so a details sidebar claims its edge the same way with one call: useSafeArea({ side: 'right', size: 352 }).

Coming from v2

placement became position, and the value vocabulary changed with it. bottom is bottom-center, bottom-start is bottom-left, bottom-end is bottom-right, and the same for top. The composable's return changed too: it is { toast, dismiss, update } now. dismissAll() is gone; dismiss() with no argument clears the stack, and nothing catches the old name for you, because it is not a template attribute either.

Variants and CSS hooks

Register the names your product needs: only registered names type-check.

ts
// nuxt.config.ts
export default defineNuxtConfig({
    bitboss: { toastVariants: ['upgrade'] },
});

The object form also sets each variant's icon for the whole app. A key registers its name (a built-in name only gets its icon):

ts
// nuxt.config.ts
export default defineNuxtConfig({
    bitboss: {
        toastVariants: {
            success: { icon: 'lucide:circle-check' },
            destructive: { icon: 'lucide:circle-x' },
            upgrade: {}, // a project variant, no icon
        },
    },
});

A toast without its own icon uses its variant's, its own wins, and hideIcon: true hides either:

ts
toast({ variant: 'success', title: 'Saved' }); // the check
toast({ variant: 'success', title: 'Sent', icon: 'lucide:send' }); // its own
toast({ variant: 'success', title: 'Copied', hideIcon: true }); // none
css
.bb-toast__panel.bb-toast__panel--upgrade {
    --icon-color: var(--bb-primary);
}

A registered variant buys you the .bb-toast__panel--<variant> class hook and nothing else. The built-ins only set --icon-color too, from --bb-success, --bb-info, --bb-warn and --bb-danger, so retuning those theme tokens retints every toast. Custom variants always announce politely, whatever they are called.

To restyle part of every toast, key the build-time pt map by BbToast; the host takes no pt of its own. The card is panel; inside it sit icon, content, title, text, footer (the row of actions), action (each button) and close, and loading is a state per toast. root is the region around one corner's stack.

bitboss-ui.pt.ts
import { definePt } from 'bitboss-ui/nuxt';

export const pt = definePt({
    BbToast: {
        action: 'min-w-20',
        footer: 'justify-end',
        'icon:loading': 'text-(--bb-text-muted)',
        'panel:loading': 'opacity-80',
    },
});

A portal toast renders its own body, so it has no icon, title, text, footer or action. Each part also carries a class (.bb-toast__panel, .bb-toast__title, …); the region's is .bb-toast. Do not fight the stack with your own z-index, and do not mount extra hosts inside an overlay to get above it: the top layer already handles that.