Skip to content

BbConfirm

The yes/no gate in front of anything irreversible — one host mounted at the app root, and a confirm() you await.

import { BbConfirm } from 'bitboss-ui';

On this page

Use it for

Use BbConfirm when an action is destructive or irreversible — delete, overwrite, publish, sign out — or otherwise deserves a deliberate second step.

Use something else when

  • BbAlert or BbToast — the message is informational rather than a decision; an alert with two buttons is not a question
  • BbDialog — the "confirm" has grown fields and validation; a confirm with an input in it is a form wearing a disguise

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.

vue
<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.

Q2 revenue review.pdf
Supplier contract — Aeris.pdf
Catalogue export 2026-08.csv

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

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

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

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

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

<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:

ts
// nuxt.config.ts
export default defineNuxtConfig({
    bitboss: { confirmVariants: ['warning'] },
});
css
.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.