Use it for
A toast is feedback 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.
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Never put information a user must act on only in a toast — it is gone before they look up.
Like BbConfirm, this is a host rather than a control. <BbToast /> carries one
prop, renders nothing where you put it, and everything you write goes through
useToast().
One host, then a function
Mount the host once, above the router, and toast() works from anywhere after
that.
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<BbToast />
</template>
<script setup lang="ts">
import { BbToast } 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 are enqueued, never rendered, and never expire — the dismiss timer lives
inside the host. With two hosts, every message renders once per host, and a
dev build says so.
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 documentation site has no host of its own, so the first demo below mounts a
<BbToast /> and every later demo on the page borrows it. Your application is the
other way round: one host at the root, and no page mounts anything.
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">
<BbButton prepend:icon="lucide:check" variant="primary" @click="saved">
Save profile
</BbButton>
<BbButton variant="outline" @click="quotaWarning">Warning</BbButton>
<BbButton variant="outline" @click="paymentFailed">Error</BbButton>
<!--
The one <BbToast /> on this page. In your app it lives at the root,
mounted once, and the demos below this one need nothing of their own.
-->
<BbToast />
</div>
</template>
<script setup lang="ts">
import { BbButton, BbToast, 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 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',
});
</script>
A variant tints the icon and sets the announcement priority — nothing else. A
success toast with no icon looks exactly like a default one, so pass an icon
whenever the tone matters; dev builds warn when a built-in variant arrives
without one, because otherwise the tone is dropped with no trace.
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 — toast() throws
otherwise. "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,
and showClose inverted into hideClose. Grep for timeout. It is an options
key rather than a template attribute, so neither the CLI checker nor the eslint
plugin sees it; TypeScript catches a literal toast({ timeout: 8000 }) on
excess-property grounds, but options assembled in a variable, or any JavaScript
call site, simply drop the key and take the default — which also shortened, from
6000ms to 4000ms.
Loading, then the result
Work that takes a moment gets one message, not two.
<template>
<div class="flex flex-wrap items-center gap-2">
<BbButton prepend:icon="lucide:upload" variant="primary" @click="onUpload">
Upload {{ file.name }}
</BbButton>
</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, so a slow
request resolving after the user cleared the corner is not a crash.
For a message that must never auto-dismiss on its own, persistent: true is the
option. 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>
<p class="text-sm opacity-70" role="status">
{{ revision }} revisions this session
</p>
</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 — autosave ticks, connection status, "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.
<template>
<div class="flex max-w-md flex-col gap-2">
<div
v-for="(reply, index) in replies"
:key="reply.id"
class="flex items-center justify-between gap-3"
>
<span class="truncate text-sm">{{ reply.text }}</span>
<BbButton
icon="lucide:trash-2"
size="xs"
variant="ghost"
@click="remove(index)"
>
Delete {{ reply.text }}
</BbButton>
</div>
<p v-if="replies.length === 0" class="text-sm opacity-70">
No saved replies left.
</p>
</div>
</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 — no confirm dialog. The action's closure
* holds the removed row, and if the toast expires the delete simply stands.
* Reach for this instead of a confirm when undoing is cheap and complete.
*/
function remove(index: number) {
const [removed] = replies.value.splice(index, 1);
if (!removed) return;
toast({
title: 'Reply deleted',
text: removed.text,
duration: 6000,
actions: [
{
text: 'Undo',
size: 'xs',
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 simply 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.
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.
A plain toast cannot show a face. This one renders a registered body instead.
<template>
<div class="flex max-w-sm flex-col gap-3">
<p class="m-0 text-sm">
A plain toast cannot show a face. This one renders a registered body
instead.
</p>
<BbButton prepend:icon="lucide:user-plus" variant="primary" @click="invite">
Invite {{ member.fullName }}
</BbButton>
<!--
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
`props` carries whatever the firing call passed as `portalProps`.
-->
<BbToastPortal v-slot="{ props, close }" name="invite">
<div class="flex items-center gap-3">
<BbAvatar :alt="member.fullName" size="40" :src="member.photo ?? undefined" />
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-medium">{{ member.fullName }} invited</p>
<p class="m-0 text-xs opacity-70">{{ props.team }} · as {{ props.role }}</p>
<div class="mt-2 flex gap-2">
<BbButton size="xs" variant="outline" @click="undo(close)">
Undo
</BbButton>
</div>
</div>
</div>
</BbToastPortal>
</div>
</template>
<script setup lang="ts">
import { BbAvatar, BbButton, BbToastPortal, useToast } from 'bitboss-ui';
import { userById } from '~/demo-data';
const member = userById[7]!;
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() {
toast({
portal: 'invite',
portalProps: { team: member.team, role: member.role },
duration: 8000,
});
}
function undo(close: () => void) {
close();
}
</script>
The portal replaces the whole body, so icon, title, text and actions stop
rendering and the portal owns its own buttons. Reach for it rarely.
Where toasts appear
Six corners: top-left, top-center, top-right, bottom-left,
bottom-center, bottom-right.
<template>
<div class="flex flex-wrap items-center gap-2">
<BbButton
v-for="corner in corners"
:key="corner"
variant="outline"
@click="fire(corner)"
>
{{ corner }}
</BbButton>
</div>
</template>
<script setup lang="ts">
import { BbButton, useToast } from 'bitboss-ui';
type Corner = 'top-left' | 'top-center' | 'bottom-right';
const corners: Corner[] = ['top-left', 'top-center', 'bottom-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: Corner) {
toast({
title: 'Link copied',
text: `This one was sent to ${position}.`,
icon: 'lucide:link',
position,
});
}
</script>
Each message resolves its corner in three steps: its own position, then the
host's position prop, 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 teleported into the browser's top layer, 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 needs one call —
useSafeArea({ side: 'right', size: 352 }) — to claim its edge the same way.
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 and they join the typed union; any plain string is still accepted at the call site for a one-off.
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { toastVariants: ['upgrade'] },
});
.bb-toast-message.bb-toast-message--upgrade {
--icon-color: var(--bb-primary);
}
A registered variant buys you the .bb-toast-message--<variant> class hook and
nothing else — the built-ins themselves only set --icon-color, so that property
is where a tone lives. Worth knowing before you theme: warning and
destructive read --bb-warn and --bb-danger, while success and info are
fixed colours, so retuning your palette leaves those two where they were unless
you override them here. Custom variants always announce politely, whatever they
are called.
The message is .bb-toast-message and the region around a stack 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.