Use it for
Use BbConfirm when an action is destructive or irreversible — delete,
overwrite, publish, sign out — or otherwise deserves a deliberate second step.
You do not place BbConfirm where the action lives, and there is no tag to
mount for it. The app's one <BbHost /> renders every confirm, and it takes no
props. The text, the buttons and what happens on dismissal all travel in the
message you pass to useConfirm().confirm(). The dialog is the render target;
the composable is the API.
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.
Setup and destructive gates
Mount <BbHost /> once, beside the router outlet, and confirm() works from
anywhere after that — components, composables, stores alike. The same host
renders every toast too.
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<!-- One per app. With named pt scopes, one per area layout,
if every page renders inside exactly one of them. -->
<BbHost />
</template>
<script setup lang="ts">
import { BbHost } 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 a
development build warns about both. With no host, await confirm(...)
resolves false after one second, so the caller moves on as if the user had
said no. With two hosts, the second renders nothing; it takes over only when
the first unmounts. The per-area layout the comment mentions is set out in
Passthrough.
This site mounts its one <BbHost /> in app.vue, so the demos below mount
nothing, and neither does a page of yours that calls confirm().
The destructive gate
await the decision, then act only on a truthy result.
Q2 revenue review.pdf
2.4 MB
Supplier contract — Aeris.pdf
795.8 KB
Catalogue export 2026-08.csv
144.8 KB
<template>
<div class="w-full max-w-md">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<ul class="m-0 list-none divide-y divide-(--bb-border) p-0">
<li
v-for="doc in docs"
:key="doc.id"
class="flex items-center gap-3 py-2"
>
<BbIcon class="shrink-0 opacity-60" icon="lucide:file-text" size="sm" />
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm">{{ doc.name }}</p>
<p class="m-0 text-xs opacity-70">{{ formatFileSize(doc.size) }}</p>
</div>
<BbButton
prepend:icon="lucide:trash-2"
size="sm"
variant="destructive"
@click="remove(doc)"
>
Delete
</BbButton>
</li>
</ul>
<p v-if="docs.length === 0" class="text-sm opacity-70">
Every document has been deleted.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbIcon, useConfirm } from 'bitboss-ui';
import { clone, files, formatFileSize } 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.
Screen readers announce the confirm as an alert dialog named by its title.
Leave the title out and the message names it instead, so a one-line
confirm({ text }) is still read aloud as a question.
Every destructive action passes through this gate. That is why a
destructive button or a
destructive menu row is worth marking: the
mark tells the next person a confirm belongs there. To announce the outcome
afterwards ("Invoice deleted"), follow the gate with a
BbToast.
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">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<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 come from the plugin's locale: "OK" and "Cancel" in English (the
default), "OK" and "Annulla" with locale: 'it'. Treat them as placeholders. The
Yes label is the last thing the user reads before committing, so make it 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 default labels follow the plugin locale, which is English
unless you set locale: 'it', 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">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<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, or `false`
* when the dialog goes away without a choice (a timeout, `dismissAll()`).
*/
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 || 'Draft, not published';
};
</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 × 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.
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.
An Inertia visit returns no promise, so wrap it in one and return that from
onClick. Yes spins for the whole visit and the dialog closes when it ends.
const destroy = (url: string) =>
new Promise<void>((resolve, reject) => {
router.delete(url, {
onSuccess: () => resolve(),
onError: (errors) => reject({ errors }),
// Cut short by another visit: resolve. A 500 or a network error: reject.
onFinish: (visit) =>
visit.cancelled || visit.interrupted
? resolve()
: reject(new Error('The delete failed')),
});
});
confirm({
title: 'Delete invoice?',
variant: 'destructive',
yes: { text: 'Delete', onClick: () => destroy(`/invoices/${id}`) },
});
Not autoClose: false with close() called from the visit's callbacks: Yes
never shows busy, and a missed callback leaves the dialog up or closes it
mid-visit.
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">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<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,
// Three labelled buttons need more than the default `sm` width.
size: 'md',
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. Three labelled buttons need a wider confirm,
size: 'md' in the message, to fit on one line each. 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">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<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 ×. Use them when both answers change the data, so an accident
* cannot pick one, and give the user two real buttons in return.
*
* The discard lives in `no.onClick`: a falsy result can also mean an unmount or
* a `duration` timeout, and neither of those discarded anything.
*/
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',
onClick: () => {
status.value = `${order.reference}: closed, 3 lines discarded`;
},
},
});
if (keep) status.value = `${order.reference}: saved and closed`;
}
</script>
A backdrop click, Escape, the ×, a duration timeout and the owning
component unmounting all resolve false. A second confirm() does not: every
call opens its own dialog. The new one covers the open one, which stays
pending and comes back when the newer one closes, so two confirms fired from
the same handler ask both, newest first. The stack option seats a confirm in
a BbDialog stack of your own instead.
if (!ok) therefore runs on all of those. no.onClick runs on No and, with the
default dismissAsNo, on the user-initiated dismissals too: the
backdrop, Escape and the ×. With returning: true, those resolve with its
return value, so give No a callback that returns a sensible "declined" value.
The system-initiated dismissals (the timeout, an owner unmounting,
dismissAll()) never run it and always resolve false. For work that must run only on an explicit No, set dismissAsNo: false.
persistent blocks the backdrop and Escape but leaves the × working;
hideClose removes the ×. Together, as in the demo, they force a real answer.
Use 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(). 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.
Coming from v2autoClose defaults to true
autoClose 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. A v2 delete that ran its request after the await and then
called close() moves the request into yes.onClick (above). Only a
flow that shows further steps passes autoClose: false and ends itself with
close(). It 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 w-full max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3">
<!-- Needs the app's one <BbHost />, mounted once at the root. -->
<BbAvatar :alt="member.fullName" size="32" :src="member.photo ?? undefined" />
<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; its `data` is the `data` the `confirm()` call passed.
-->
<BbConfirmPortal name="remove-member">
<template #default="{ data }">
<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 }} · {{ data.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',
data: { 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
{ data, yes, no }. data is the data you passed. yes / no
resolve the dialog from inside the body. When the portal owns its own buttons,
pass actions: false to hide the standard footer. The slot is a live closure over your
component's state, so it can read that state directly.
A typed confirmation is that case: the portal's own Delete stays disabled until the name matches.
vantera-web
14 deployments · production
<template>
<div class="flex w-full max-w-md items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3">
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm font-medium">{{ project }}</p>
<p class="m-0 text-xs opacity-70" role="status">{{ status }}</p>
</div>
<BbButton
prepend:icon="lucide:trash-2"
size="sm"
variant="destructive"
@click="remove"
>
Delete project
</BbButton>
<!-- The portal owns the buttons, so Delete can wait for the typed name. -->
<BbConfirmPortal name="delete-project">
<template #default="{ yes, no }">
<div class="grid gap-3">
<p class="m-0 text-sm">
This deletes 14 deployments and every environment variable.
Type <strong>{{ project }}</strong> to confirm.
</p>
<BbTextInput
id="confirm-typed-name"
v-model="typed"
autocomplete="off"
compact
hide-label
label="Project name"
name="confirmProjectName"
:placeholder="project"
/>
<div class="flex justify-end gap-2">
<BbButton variant="ghost" @click="no">Cancel</BbButton>
<BbButton
:disabled="typed !== project"
variant="destructive"
@click="yes"
>
Delete project
</BbButton>
</div>
</div>
</template>
</BbConfirmPortal>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbConfirmPortal, BbTextInput, useConfirm } from 'bitboss-ui';
const project = 'vantera-web';
const typed = ref<string | null>('');
const status = ref('14 deployments · production');
const { confirm } = useConfirm();
async function remove() {
typed.value = '';
const ok = await confirm({
title: 'Delete this project?',
portal: 'delete-project',
// Hide the standard footer; the portal's buttons resolve the dialog.
actions: false,
focusTarget: '#confirm-typed-name',
variant: 'destructive',
});
if (ok) status.value = 'Project deleted';
}
</script>
text takes priority over portal: with both set, the portal never renders.
Remove the text. bitboss-ui check reads every literal portal: 'name' against
the <BbConfirmPortal name>s in your templates, so a misspelt name fails the
check instead of rendering an empty body.
For a body you reuse across the app, pass a component as portal instead of a
name, and the keys of data are forwarded to it as props 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__content,
.bb-confirm.bb-confirm--warning .bb-offcanvas__content {
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). The plugin's confirm: { defaultSize }
sets the default for every confirm; a per-call size wins.
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { confirm: { defaultSize: 'md' } },
});
Every footer button, Yes, No or an actions entry, carries
.bb-confirm__action, and an action key in the build-time
pt.BbConfirm map styles
all of them at once. For one button in one confirm, use its config: variant,
size, icons.
Coming from v2
.bb-confirm__no and .bb-confirm__yes are gone, and a rule hanging off
either one stops matching; npx bitboss-ui check reports it. Both buttons are .bb-confirm__action
now; tell them apart through the per-call button config. .bb-confirm__content
and .bb-confirm__text survive and are still the right targets for the body
copy.