Use it for
Use BbConfirm when an action is destructive or irreversible — delete,
overwrite, publish, sign out — or otherwise deserves a deliberate second step.
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
It is not a component you place where the action lives. <BbConfirm /> takes no
props at all. You mount it once, at the root of the application, and everything
— the text, the buttons, the size, what happens on dismissal — travels in the
message you pass to useConfirm().confirm(). The dialog is the render target; the
composable is the API.
Setup and destructive gates
Mount the host once, above the router, and confirm() works from anywhere after
that — components, composables, stores alike.
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<BbConfirm />
</template>
<script setup lang="ts">
import { BbConfirm } from 'bitboss-ui';
</script>
Under Vue that file is App.vue, under Nuxt app.vue, under Inertia the
createInertiaApp render root. There are two ways to get this wrong and both are
quiet. With no host, await confirm(...) never settles and the caller hangs
forever — a dev build warns once, a production build does not. With two hosts,
the same dialog renders once per host, and the warning names the count.
A page that calls confirm() mounts nothing.
This documentation site has no host of its own, so the first demo below mounts a
<BbConfirm /> and every later demo on the page borrows it. Your application is
the other way round.
One more thing to know before writing any of it: a second confirm() while one
is open replaces the visible dialog. The superseded promise resolves false
as an implicit dismissal, with no callback run, so nothing hangs — but two
confirms fired from the same handler show only the second.
The destructive gate
await the decision, then act only on a truthy result.
<template>
<div class="flex max-w-md flex-col gap-2">
<div
v-for="doc in docs"
:key="doc.id"
class="flex items-center justify-between gap-3"
>
<span class="truncate text-sm">{{ doc.name }}</span>
<BbButton
prepend:icon="lucide:trash-2"
size="xs"
variant="destructive"
@click="remove(doc)"
>
Delete
</BbButton>
</div>
<p v-if="docs.length === 0" class="text-sm opacity-70">
Every document has been deleted.
</p>
<!--
The one <BbConfirm /> on this page. In your app it lives at the root,
mounted once, and the demos below this one need nothing of their own.
-->
<BbConfirm />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbConfirm, useConfirm } from 'bitboss-ui';
import { clone, files } from '~/demo-data';
import type { DemoFile } from '~/demo-data';
const docs = ref<DemoFile[]>(clone(files).slice(0, 3));
const { confirm } = useConfirm();
/** Await the decision, then act. Everything after the await is ordinary code. */
async function remove(doc: DemoFile) {
const ok = await confirm({
title: `Delete "${doc.name}"?`,
text: 'The document is removed for everyone in the workspace, permanently.',
variant: 'destructive',
yes: { text: 'Delete', 'prepend:icon': 'lucide:trash-2' },
no: 'Keep it',
});
if (ok) docs.value = docs.value.filter((entry) => entry.id !== doc.id);
}
</script>
Everything after the await is ordinary code: the dialog has already resolved and
closed. variant: 'destructive' colours the dialog as dangerous and makes the Yes
button destructive too, so the two never disagree about how serious this is.
Per the library's design language, every destructive action passes through this
gate. It is also the reason a
destructive button and a
destructive menu row are worth marking as such:
the mark is what tells the next person a confirm belongs there.
Labelling the choice
yes and no each take three forms: false hides the button, a string relabels
it, and an object gives you the whole BbButton surface.
Aeris Ergonomic Task Chair is live
<template>
<div class="flex flex-wrap items-center gap-3">
<BbButton prepend:icon="lucide:archive" variant="outline" @click="archive">
Archive product
</BbButton>
<BbButton variant="ghost" @click="acknowledge">Show release note</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useConfirm } from 'bitboss-ui';
import { products } from '~/demo-data';
const product = products[0]!;
const status = ref(`${product.name} is live`);
const { confirm } = useConfirm();
/** A full config on each button: label, icon, variant. */
async function archive() {
const ok = await confirm({
title: 'Archive this product?',
text: `${product.name} disappears from the catalogue. You can restore it from the archive later.`,
yes: { text: 'Archive', 'prepend:icon': 'lucide:archive' },
no: { text: 'Cancel', variant: 'ghost' },
});
status.value = ok ? `${product.name} archived` : `${product.name} is live`;
}
/** `no: false` leaves one button — an acknowledgement rather than a question. */
async function acknowledge() {
await confirm({
title: 'Catalogue 4.2 is live',
text: 'Prices and availability were refreshed for every region.',
yes: 'Got it',
no: false,
});
}
</script>
The defaults are localised through the plugin's locale — "OK" and "Annulla"
under the default Italian locale. Treat them as placeholders rather than
recommendations: the Yes label is the last thing the user reads before committing,
so it should be the verb of the action. Delete, Archive, Publish. "Are you
sure? / OK" tells nobody what is about to happen.
no: false leaves one button, which is how you write an acknowledgement rather
than a question. The object form takes text, variant, size,
prepend:icon / append:icon, icon for an icon-only button, block, and the
onClick the next section is about.
Coming from v2
yesText, onYes, noText and onNo collapse into these two options. yes: false and no: false are unchanged — do not "migrate" either into actions: false, which hides the whole footer instead of one button. Two silent
differences: the labels are localised now, so an English app stops showing the
Italian default, and the footer buttons default to md where v2 used lg.
Work before the dialog closes
Some work has to finish before the dialog goes away — the user should see the delete happen, not discover it later.
Draft — not published
<template>
<div class="flex flex-wrap items-center gap-3">
<BbButton prepend:icon="lucide:rocket" variant="primary" @click="onPublish">
Publish price change
</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useConfirm } from 'bitboss-ui';
import { delay, products } from '~/demo-data';
const product = products[1]!;
const status = ref('Draft — not published');
const { confirm } = useConfirm();
/**
* The work is inside the button's onClick, not after the await: the Yes button
* spins, No is disabled and the dialog refuses to close until it settles.
* `returning: true` resolves with the callback's own return value.
*/
const onPublish = async () => {
const result = await confirm({
title: 'Publish the new price?',
text: `${product.name} goes live at the updated price for every channel.`,
returning: true,
yes: {
text: 'Publish',
'prepend:icon': 'lucide:rocket',
onClick: async () => {
await delay(null, 1200);
return 'Published to every channel';
},
},
no: { text: 'Keep as draft', onClick: () => 'Draft — not published' },
});
status.value = result;
};
</script>
Put it in the button's onClick instead of after the await. The button shows a
spinner while the promise runs, the opposite button is disabled, the X is hidden
and every dismissal is blocked until it settles. returning: true then resolves
the promise with the callback's own return value rather than a plain boolean.
One sharp edge: confirm() rejects when a button's onClick throws. Wrap the
await in try/catch whenever the work can fail, or a failed delete becomes an
unhandled rejection instead of a message.
Keep those callbacks short. While one runs there is no way out of the dialog by design, so a slow one is a frozen screen.
Outcomes and dismissal
When the decision has three answers, an actions array replaces the yes/no
footer entirely.
Editing — nothing decided yet
<template>
<div class="flex flex-wrap items-center gap-3">
<BbButton prepend:icon="lucide:x" variant="outline" @click="closeEditor">
Close editor
</BbButton>
<p class="text-sm opacity-70" role="status">{{ decision }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useConfirm } from 'bitboss-ui';
import { delay } from '~/demo-data';
const decision = ref('Editing — nothing decided yet');
const { confirm } = useConfirm();
/**
* An `actions` array replaces the Yes/No footer. With `returning: true` the
* promise resolves with the clicked handler's return value; `as const` is what
* keeps the literal types instead of widening them to `string`.
*/
async function closeEditor() {
const result = await confirm({
title: 'You have unsaved changes',
text: 'Decide what happens to this draft before the editor closes.',
returning: true,
actions: [
{
text: 'Discard',
variant: 'destructive',
onClick: () => 'discarded' as const,
},
{ text: 'Save as draft', onClick: () => 'saved as a draft' as const },
{
text: 'Save and publish',
variant: 'primary',
onClick: async () => {
await delay(null, 1000);
return 'saved and published' as const;
},
},
],
});
decision.value =
result === false ? 'Dismissed — nothing changed' : `Changes ${result}`;
}
</script>
Each entry is the same button config, defaulting to outline and md. Clicking
any of them resolves the promise true — or, with returning: true, with that
handler's return value, typed as the union across the array. as const on the
returns is what keeps the literal types instead of widening them all to string.
yes and no are ignored when actions is present, with a dev warning. Every
implicit dismissal still resolves false without running anything, so a
three-way decision always has a fourth outcome: none of them.
Three is about the ceiling. A footer with five buttons is a form that has not admitted it yet.
What false actually means
false means not confirmed. It does not mean declined.
ORD-2026-0418 — editing, 3 unsaved lines
<template>
<div class="flex flex-wrap items-center gap-3">
<BbButton variant="outline" @click="leave">Leave the order form</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, useConfirm } from 'bitboss-ui';
import { orders } from '~/demo-data';
const order = orders[1]!;
const status = ref(`${order.reference} — editing, 3 unsaved lines`);
const { confirm } = useConfirm();
/**
* `persistent` and `hideClose` close every implicit exit: no backdrop click, no
* Escape, no X. Use them when both answers change the data, so an accident
* cannot pick one — and give the user two real buttons in return.
*/
async function leave() {
const keep = await confirm({
title: 'Keep your unsaved lines?',
text: `Three lines on ${order.reference} are not saved yet.`,
persistent: true,
hideClose: true,
yes: { text: 'Save and leave', 'prepend:icon': 'lucide:save' },
no: { text: 'Discard them', variant: 'destructive' },
});
status.value = keep
? `${order.reference} — saved and closed`
: `${order.reference} — closed, 3 lines discarded`;
}
</script>
A backdrop click, Escape, the X, a duration timeout and the owning component
unmounting all resolve false. So decline-only behaviour belongs in no.onClick,
never in the if (!ok) branch — put it there and it runs every time someone
presses Escape.
Which of those actually run no.onClick is a clean rule: user-initiated
dismissals (backdrop, Escape, the X) honour dismissAsNo, which defaults to
true; system-initiated ones (the timeout, an owner unmounting, a superseding
confirm(), dismissAll()) always resolve quietly. persistent blocks the
user-initiated ones and hideClose removes the X; together, as in the demo, they
force a real answer. Reach for them when both answers change the data.
Dialogs also die with the scope that opened them. A confirm belongs to the
effect scope that called useConfirm(), so if the user presses Back or a route
change swaps the page while the dialog is open, it is dismissed quietly: the
promise resolves false and no callback runs, because the handlers close over
state that no longer exists. Calling useConfirm() at page level is the usual
choice; call it outside a component — app setup, a store, module scope — for
confirms that must survive any unmount. dismissAll() is the imperative version
for app-level events like a logout.
Migrating from v2: autoClose now defaults to true where v2 made it
opt-in. A multi-step flow that relied on the dialog staying open after the
answer now closes under it, and nothing warns. Pass autoClose: false and end the
flow yourself with close() — remembering that close() only hides the dialog
and never resolves anything, so calling it before a button has run leaves that
promise pending forever.
A body that is not a sentence
When plain text cannot carry the decision, render your own content in the body.
Sofia Marchetti
Design · joined 2020
<template>
<div class="flex max-w-md flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm font-medium">{{ member.fullName }}</p>
<p class="m-0 text-xs opacity-70" role="status">{{ status }}</p>
</div>
<BbButton size="sm" variant="destructive" @click="removeMember">
Remove
</BbButton>
<!--
Registers content by name and renders nothing where it sits. The slot
is a live closure over this component's state, so it can read `member`
directly — `props` carries whatever `portalProps` was passed.
-->
<BbConfirmPortal name="remove-member">
<template #default="{ props }">
<div class="flex items-center gap-3">
<BbAvatar :alt="member.fullName" size="40" :src="member.photo ?? undefined" />
<div class="min-w-0">
<p class="m-0 text-sm font-medium">{{ member.fullName }}</p>
<p class="m-0 text-xs opacity-70">
{{ member.role }} · {{ props.projects }} shared projects
</p>
</div>
</div>
</template>
</BbConfirmPortal>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAvatar, BbButton, BbConfirmPortal, useConfirm } from 'bitboss-ui';
import { userById } from '~/demo-data';
const member = userById[3]!;
const status = ref(`${member.team} · joined ${member.joinedAt.slice(0, 4)}`);
const { confirm } = useConfirm();
/** `text` wins over `portal`, so a portal body means no `text` at all. */
async function removeMember() {
const ok = await confirm({
title: 'Remove this member?',
portal: 'remove-member',
portalProps: { projects: 4 },
variant: 'destructive',
yes: 'Remove',
no: 'Cancel',
});
if (ok) status.value = 'Removed from the workspace';
}
</script>
<BbConfirmPortal name="…"> registers content and renders nothing where it sits,
so it can live next to the thing it describes. Its slot receives
{ yes, no, props } — props is the portalProps you passed, and yes / no
resolve the dialog from inside the body, which is what actions: false is for
when the portal owns its own buttons. Because the slot is a live closure over your
component's state, it can also read that state directly.
text takes priority over portal: with both set, the portal never renders.
Remove the text.
For a body you reuse across the app, pass a component as portal instead of a
name and portalProps are forwarded to it with v-bind. And focusTarget takes
a CSS selector, for pointing focus at an input inside the body rather than at the
first tabbable thing.
If that body grows a second field and a validation rule, stop. It is a BbDialog
now.
Variants, size and the sheet
variant lands on the dialog root as bb-confirm--<name>. default and
destructive ship with the library; register the rest and they become typed
values:
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { confirmVariants: ['warning'] },
});
.bb-confirm.bb-confirm--warning .bb-dialog__body,
.bb-confirm.bb-confirm--warning .bb-offcanvas__body {
color: var(--bb-text-warn);
}
Both selectors are needed because a confirm has two surfaces: on small viewports
it follows the global adaptive config and renders as a bottom sheet instead of a
dialog, with no per-call override. Style .bb-dialog__* and .bb-offcanvas__*
together or your skin only exists on one of them. size picks a maximum-width
preset from xs to 2xl, and fullscreen takes over the screen — 'mobile'
restricts that to small viewports.
Coming from v2
.bb-confirm__no and .bb-confirm__yes are gone. The footer buttons render as
bare BbButtons with no confirm-specific class, so a rule hanging off either
one stops matching in silence; restyle them per call through the button config
instead. .bb-confirm__content and .bb-confirm__text survive and are still
the right targets for the body copy.