Skip to content

BbConfirm

The yes/no gate in front of anything irreversible: one host 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

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.

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

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

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

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.

ts
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

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

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

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

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:

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

ts
// 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.