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.
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.
<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
| Option | Default | What it does |
|---|---|---|
title | none | The bold line; toast('…') sets only this |
text | none | Body copy under the title |
variant | 'default' | success, info, warning, destructive or a registered name |
icon | the variant's | The leading icon, such as 'lucide:circle-check'; wins over the variant's |
hideIcon | false | Hides the icon, the variant's or its own; the spinner still shows |
loading | false | A spinner in place of the icon; keeps the toast open |
duration | 4000 | Milliseconds on screen, always above zero |
persistent | loading | Never leaves on its own |
hideClose | false | Hides the ×; with persistent, only when your code calls dismiss(id) |
position | the plugin's toastPosition | One of the six corners |
actions | none | Buttons under the text |
portal, data | none | A <BbToastPortal> name, or a component, that renders the whole body; data is what it renders |
id | generated | Reuse 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.
<template>
<div class="flex flex-wrap items-center gap-2">
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
<BbButton prepend:icon="lucide:check" variant="primary" @click="saved">
Save profile
</BbButton>
<BbButton variant="outline" @click="announceRelease">
Check for updates
</BbButton>
<BbButton variant="outline" @click="quotaWarning">Upload to storage</BbButton>
<BbButton variant="outline" @click="paymentFailed">Pay invoice</BbButton>
<BbButton variant="outline" @click="copied">Copy link</BbButton>
</div>
</template>
<script setup lang="ts">
import { BbButton, useToast } from 'bitboss-ui';
import { userById } from '~/demo-data';
const user = userById[7]!;
const { toast } = useToast();
/** A variant tints the icon and sets the announcement priority, nothing else,
* so a variant without an icon is a tone nobody can see. */
const saved = () =>
toast({
title: 'Profile updated',
text: `${user.fullName} is live for the whole team.`,
variant: 'success',
icon: 'lucide:circle-check',
});
const announceRelease = () =>
toast({
title: 'New version available',
text: 'Reload to get the latest invoice templates.',
variant: 'info',
icon: 'lucide:info',
});
const quotaWarning = () =>
toast({
title: 'Storage almost full',
text: '92% of the workspace quota is in use.',
variant: 'warning',
icon: 'lucide:triangle-alert',
});
const paymentFailed = () =>
toast({
title: 'Payment failed',
text: 'The card ending 4242 was declined.',
variant: 'destructive',
icon: 'lucide:circle-x',
});
// The shorthand: a plain string, no variant, no icon.
const copied = () => toast('Link copied to the clipboard.');
</script>
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.
<template>
<div class="flex flex-wrap items-center gap-2">
<!--
The handler is fired and forgotten: returning its promise would make the
button spin too, and one wait would show two loaders. The toast is the
only indicator.
-->
<BbButton
prepend:icon="lucide:upload"
variant="primary"
@click="() => void onUpload()"
>
Upload {{ file.name }}
</BbButton>
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
</div>
</template>
<script setup lang="ts">
import { BbButton, useToast } from 'bitboss-ui';
import { delay, files, formatFileSize } from '~/demo-data';
const file = files[2]!;
const { toast, update } = useToast();
/**
* `loading: true` pins the toast open: it implies `persistent`, so the timer
* never runs. Keep the id and morph the same message when the work settles;
* setting `loading: false` in the update is what releases the pin.
*/
const onUpload = async () => {
const id = toast({ title: `Uploading ${file.name}…`, loading: true });
await delay(null, 1600);
update(id, {
title: 'Upload complete',
text: `${file.name} · ${formatFileSize(file.size)}`,
variant: 'success',
icon: 'lucide:circle-check',
loading: false,
});
};
</script>
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
<template>
<div class="flex flex-wrap items-center gap-3">
<BbButton prepend:icon="lucide:save" variant="outline" @click="save">
Save draft
</BbButton>
<!-- No role="status": the toast already announces each save. -->
<p class="m-0 text-sm text-(--bb-text-muted)">
{{ revision }} {{ revision === 1 ? 'revision' : 'revisions' }} this session
</p>
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useToast } from 'bitboss-ui';
const revision = ref(0);
const { toast } = useToast();
/**
* Passing your own id turns toast() into an upsert: press the button ten times
* and one message is patched ten times, its countdown restarting each time,
* instead of ten messages piling into the corner.
*/
function save() {
revision.value += 1;
toast({
id: 'draft-autosave',
title: 'Draft saved',
text: `Revision ${revision.value} stored.`,
variant: 'success',
icon: 'lucide:cloud-upload',
});
}
</script>
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
<template>
<section
aria-labelledby="saved-replies-title"
class="flex max-w-md flex-col rounded-(--bb-radius) border border-(--bb-border) text-sm"
>
<h3
id="saved-replies-title"
class="m-0 border-b border-(--bb-border) px-4 py-2.5 text-sm font-semibold"
>
Saved replies
</h3>
<ul class="m-0 flex list-none flex-col p-0">
<li
v-for="(reply, index) in replies"
:key="reply.id"
class="flex items-center justify-between gap-3 border-t border-(--bb-border) px-4 py-2 first:border-t-0"
>
<span class="truncate">{{ reply.text }}</span>
<BbButton
icon="lucide:trash-2"
size="xs"
variant="ghost"
@click="remove(index)"
>
Delete “{{ reply.text }}”
</BbButton>
</li>
</ul>
<p v-if="replies.length === 0" class="m-0 px-4 py-3 text-(--bb-text-muted)">
No saved replies left.
</p>
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useToast } from 'bitboss-ui';
type Reply = { id: string; text: string };
const replies = ref<Reply[]>([
{ id: 'thanks', text: 'Thanks, we are on it' },
{ id: 'refund', text: 'Your refund is on its way' },
{ id: 'escalate', text: 'Escalated to engineering' },
]);
const { toast } = useToast();
/**
* The delete commits immediately, with no confirm dialog. The action's closure
* holds the removed row, and if the toast expires the delete simply stands.
* The Undo button is a small primary one: the default for toast actions.
*/
function remove(index: number) {
const [removed] = replies.value.splice(index, 1);
if (!removed) return;
toast({
title: 'Reply deleted',
text: `“${removed.text}”`,
icon: 'lucide:trash-2',
duration: 6000,
actions: [
{
text: 'Undo',
onClick: () => replies.value.splice(index, 0, removed),
},
],
});
}
</script>
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
Financial Analyst, Finance
<template>
<section
aria-label="Suggested teammate"
class="flex max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3 text-sm"
>
<BbAvatar :alt="member.fullName" size="40" :src="member.photo ?? undefined">
{{ member.initials }}
</BbAvatar>
<div class="min-w-0 flex-1">
<p class="m-0 truncate font-medium">{{ member.fullName }}</p>
<p class="m-0 truncate text-xs text-(--bb-text-muted)">
{{ member.jobTitle }}, {{ member.team }}
</p>
</div>
<BbButton
v-if="!invited"
prepend:icon="lucide:user-plus"
size="sm"
variant="outline"
@click="invite"
>
Invite
</BbButton>
<span v-else class="text-xs text-(--bb-text-muted)">Invited</span>
<!--
Renders nothing where it sits: it registers a body by name. The slot is
a live closure over this component, so it reads `member` directly, while
its `data` is the `data` the firing `toast()` call passed.
-->
<BbToastPortal v-slot="{ data, close }" name="invite">
<div class="flex items-center gap-3">
<BbAvatar :alt="member.fullName" size="40" :src="member.photo ?? undefined">
{{ member.initials }}
</BbAvatar>
<div class="min-w-0 flex-1">
<!-- The same type ramp as a built-in toast: 13px, semibold title. -->
<p class="m-0 text-[13px] font-semibold">{{ member.firstName }} is invited</p>
<p class="m-0 text-[13px] text-(--bb-text-muted)">
Joins {{ data.team }} as {{ data.role }}
</p>
</div>
<BbButton size="sm" @click="undo(close)">Undo</BbButton>
</div>
</BbToastPortal>
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAvatar, BbButton, BbToastPortal, useToast } from 'bitboss-ui';
import { userById } from '~/demo-data';
const member = userById[7]!;
const invited = ref(false);
const { toast } = useToast();
/**
* A portal body replaces the whole toast body, so `icon`, `title`, `text` and
* `actions` stop rendering and the portal owns its own buttons.
*/
function invite() {
invited.value = true;
toast({
portal: 'invite',
data: { team: member.team, role: member.role },
duration: 8000,
});
}
function undo(close: () => void) {
invited.value = false;
close();
}
</script>
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.
<template>
<!-- A miniature screen: each button sits in the corner it sends a toast to. -->
<div
class="flex aspect-video w-full max-w-md flex-col justify-between rounded-(--bb-radius) border border-(--bb-border) p-2"
>
<div
v-for="row in rows"
:key="row.edge"
class="flex items-center justify-between gap-2"
>
<!-- The arrow points at the corner. Below sm the label is visually
hidden but still names the button. -->
<BbButton
v-for="corner in row.corners"
:key="corner.position"
:prepend:icon="corner.icon"
size="sm"
variant="outline"
@click="fire(corner)"
>
<span class="sr-only sm:not-sr-only">{{ corner.label }}</span>
</BbButton>
</div>
<!-- Fires into the app's one <BbHost />, mounted once at the root. -->
</div>
</template>
<script setup lang="ts">
import { BbButton, useToast } from 'bitboss-ui';
type Corner = {
position:
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
label: string;
icon: string;
};
const rows: { edge: string; corners: Corner[] }[] = [
{
edge: 'top',
corners: [
{ position: 'top-left', label: 'Top left', icon: 'lucide:arrow-up-left' },
{ position: 'top-center', label: 'Top center', icon: 'lucide:arrow-up' },
{
position: 'top-right',
label: 'Top right',
icon: 'lucide:arrow-up-right',
},
],
},
{
edge: 'bottom',
corners: [
{
position: 'bottom-left',
label: 'Bottom left',
icon: 'lucide:arrow-down-left',
},
{
position: 'bottom-center',
label: 'Bottom center',
icon: 'lucide:arrow-down',
},
{
position: 'bottom-right',
label: 'Bottom right',
icon: 'lucide:arrow-down-right',
},
],
},
];
const { toast } = useToast();
/**
* Each corner is an independent stack, so a message sent to one never
* reorders the others. In a real product pick one corner and stay there:
* per-message positions are for the rare control that sits somewhere else.
*/
function fire({ position, label }: Corner) {
toast({
title: 'Link copied',
text: `Sent to ${label.toLowerCase()} (${position}).`,
icon: 'lucide:link',
position,
});
}
</script>
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.
// 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):
// 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:
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
.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.
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.