NbConfirm asks one question, in one shape, everywhere: is this the thing you meant to destroy?
You almost never mount it yourself. useConfirm() returns an awaitable function, so the guard reads as a single if:
import { useConfirm } from '@nubisco/ui'
const confirm = useConfirm()
async function remove(environment) {
if (
await confirm({
title: 'Delete environment',
subjectLabel: 'Environment',
subject: environment.name,
message: 'Every entry, release and API key in it is deleted.',
confirmLabel: 'Delete environment',
})
) {
await api.deleteEnvironment(environment.id)
}
}Live demo
const confirm = useConfirm()
const answered = await confirm({
title: 'Delete environment',
subjectLabel: 'Environment',
subject: 'staging-eu',
message: 'Every entry, release and API key in it is deleted.',
confirmLabel: 'Delete environment',
})What this component decides for you
Reading the twelve applications that consume this library turned up seven different confirmation mechanics and a long tail of destructive actions with no confirmation at all: deleting a CMS environment and every entry in it behind a window.confirm, regenerating an OAuth client secret on a single unguarded click, a localStorage.clear() in a command palette, deleting another user's comment. The variation was never a design choice, it was the cost of deciding again at every call site. So the decisions live here:
| Decision | What NbConfirm does | Why it is not a prop |
|---|---|---|
| Button order | Cancel first, confirm second | The exit is where the reading eye arrives first and the commit is where it stops. Flipping this per app makes muscle memory a hazard. |
| Button emphasis | Ghost cancel, danger confirm (primary when tone="neutral") | A destructive action styled as a primary button is the one defect the old modal docs actively taught. |
| Initial focus | Cancel | The dialog appears under the hand that just clicked. A confirm button holding focus turns one stray Space into a deletion. |
| Esc, backdrop, close button | All resolve to cancel, and all three are refused while busy (the close button is disabled and aria-disabled for as long as it is refused, never live-but-inert) | Three exits that mean the same thing. None of them can ever mean confirm, and none of them can un-fire a request that has already left. |
| Enter | Nothing globally | An accept-on-Enter default fires the destructive action from a keystroke aimed at something else. |
| Focus containment | Tab cycles inside the dialog, and focus moved by anything else is pulled back | Behind the scrim sits the list you are deleting from. NbModal traps focus too; NbConfirm adds the rules for its pending state and its type-to-confirm gate. |
| Depth | One Esc answers one question: only the surface in front of you responds, whether that is a confirm over a modal or a confirm over a confirm | Every open dialog listens on the document, so without a topmost test one press would answer two questions, and the oldest would win. |
| Naming | The dialog is labelled by its title and described by its body | Otherwise a screen reader announces "dialog" and nothing else. |
The parts that stay yours are the words (title, message, confirmLabel) and the record (subject).
Name the record, not the category
"Are you sure?" is not a question, it is a speed bump. The user already knows they clicked delete. What they do not know is which row they clicked.
subject renders the record verbatim, in monospace, in its own strip, so an identifier that differs by one character is readable rather than skimmable. subjectLabel says what kind of thing it is.
await confirm({
title: 'Revoke API key',
subjectLabel: 'Key',
subject: 'nb_live_7f3a91c0',
message: 'Any integration still using this key stops working immediately.',
confirmLabel: 'Revoke key',
})For a consequence that a sentence cannot carry (a count, a list, the names of the things that go with it), pass body, a render function that fills the dialog's default slot:
import { h } from 'vue'
await confirm({
title: 'Delete environment',
subjectLabel: 'Environment',
subject: 'production',
body: () =>
h('ul', [
h('li', '4 releases'),
h('li', '2 API keys'),
h('li', '1,204 entries'),
]),
confirmLabel: 'Delete environment',
})It renders inside the dialog's own Vue app, which is not your application's: no router, no i18n, no provides. Resolve anything you need from those before calling confirm() and close over the result. Plain text belongs in message. If you are using the component directly rather than the composable, use the default slot instead; body is how that slot is reached from an options object. The footer stays owned by the component either way.
Work that takes time
A confirm that fires a network call has a second failure mode: the user clicks confirm, nothing visibly happens, and they click again. Two DELETE requests, one of which is about to 404 loudly.
Hand onConfirm to the composable and the dialog owns the whole span. While the promise is pending both buttons lock, the confirm button spins, a live region announces the wait, and Esc, the backdrop and the close button stop dismissing (the action has already been fired, and closing the dialog cannot un-fire it). The promise confirm() returned resolves true only once the work has succeeded.
If onConfirm rejects, nothing resolves yet: the dialog stays open with the failure shown, so the user can retry or cancel with the error still in front of them.
const deleted = await confirm({
title: 'Delete environment',
subject: 'production',
confirmLabel: 'Delete environment',
busyLabel: 'Deleting...',
onConfirm: () => api.deleteEnvironment(id),
formatError: (error) => humanise(error),
})
// `deleted` is true only if the request came back OK.A promise that never settles
The lock is the point: while the work is in flight nothing dismisses the dialog, because a fired request cannot be un-fired by closing the window that fired it. That makes a hung promise the one state a user cannot get out of, so timeout converts it into one they can:
await confirm({
title: 'Delete environment',
confirmLabel: 'Delete environment',
onConfirm: () => api.deleteEnvironment(id),
timeout: 15000,
timeoutMessage: 'The API did not answer. It may still be running.',
})After timeout milliseconds the dialog leaves the pending state and shows timeoutMessage as an ordinary failure: the buttons come back, and the user can retry or cancel. It does not abort the work, because nothing here can cancel a request it did not make; if the call itself has to stop, pass your own AbortSignal inside onConfirm. There is no default, because a library guess at how long "too long" is would be wrong for a batch job and right for nothing.
Read this before setting one. The timeout hands the buttons back while the first request may still be running, so a retry fires a second request alongside the first: the exact double-fire the lock exists to prevent, reopened deliberately because a dialog nobody can close is worse. The composable protects the answer but not the side effect. When the first request eventually lands it is disowned, so it cannot resolve your promise, close the dialog or print its error over a different question, but nothing here can un-send it. Set timeout only where the call is idempotent or the API rejects a duplicate, phrase timeoutMessage so the user knows the work may still be running (the default does), and pass an AbortSignal inside onConfirm when the request itself must stop.
The finished beat
By default the dialog closes the moment the work returns, which is right for anything quick. For a call slow enough that its disappearance reads as a glitch, successLabel holds the result on screen for a beat first:
await confirm({
title: 'Delete environment',
confirmLabel: 'Delete environment',
busyLabel: 'Deleting...',
successLabel: 'Environment deleted',
onConfirm: () => api.deleteEnvironment(id),
})During that beat the dialog stays locked: the question has been answered, so nothing may answer it again. The promise resolves true as the dialog closes. It is opt-in, because a confirmation that lingers after a successful delete costs time on the common path.
Type-to-confirm
Some actions are bigger than the button that starts them: an environment and all of its content, a production secret, a tenant. For those, typeToConfirm disables the confirm button until the user reproduces the exact string.
await confirm({
title: 'Delete environment',
subjectLabel: 'Environment',
subject: 'production',
message: 'This deletes 1,204 entries, 38 releases and 4 API keys.',
confirmLabel: 'Delete environment',
typeToConfirm: 'production',
typeToConfirmHelper: 'Case sensitive.',
})The match ignores surrounding whitespace (a name pasted with a trailing space is the same intent) and is case sensitive (the friction is the point). The field is cleared every time the dialog opens, so a phrase typed for one record can never carry over to the next one.
Use it sparingly. A gate on a routine delete trains people to type the phrase without reading it, which costs the same attention it was meant to buy, and it is the one part of this dialog a game controller cannot drive without an on-screen keyboard.
When not to ask at all
A confirmation dialog is an interruption that buys back an undo you did not build. If the action is reversible, prefer the undo: delete the row, show an NbToast with an undo action, and ask nothing. Reach for NbConfirm when the action is irreversible, expensive, affects other people, or reaches further than the control suggests.
Do not use it to report a result (that is a toast), to collect a form (that is NbModal), or to stack a second question on top of a first: confirm() queues concurrent requests and shows them one at a time, so two scrims can never pile up.
Why not window.confirm
It is the mechanism most of the fleet reached for, and it is unavailable to some of it:
- Inside a Tauri webview it is a documented no-op. The dialog never appears and the call returns immediately, so the guard silently evaluates as "cancelled" (or, worse in the inverted spellings, as "confirmed"). Two of our apps hit this independently.
- It cannot be driven by a gamepad.
NbConfirmis ordinary DOM with real focus, so anything that produces Tab, Space and Esc drives it, controller mappings included. - It blocks the main thread, cannot be themed, cannot name the record in monospace, cannot show a pending state, and some browsers let the user suppress it permanently after the second appearance.
Migrating
// Before
if (window.confirm('Delete this environment?')) {
await api.deleteEnvironment(env.id)
}
// After
if (
await confirm({
title: 'Delete environment',
subjectLabel: 'Environment',
subject: env.name,
message: 'Every entry, release and API key in it is deleted.',
confirmLabel: 'Delete environment',
})
) {
await api.deleteEnvironment(env.id)
}The shape of the call site does not change, which is deliberate: if (await confirm(...)) is a drop-in for if (window.confirm(...)), so migrating is renaming plus filling in the words that the native dialog never had room for.
Nothing to install
The first confirm() call creates its own host and mounts it on document.body. There is no plugin to register and no component to remember in App.vue, because "the team forgot to mount the host" is exactly the silent failure this replaces.
In a non-browser environment (SSR, a Node script) there is nothing to mount, and both possible answers would be lies, so confirm() throws with a message that says so rather than resolving. Call it from an event handler, not from setup().
That host is its own Vue app with no plugin installed, so nothing is registered globally in it except what useConfirm() registers by hand. A sibling that Vue cannot resolve there would render as an unstyled unknown element, and Vue only warns about it in development, so the host promotes that particular warning to a console.error naming the file to fix. The dialog is still shown: a broken dialog is better than a deleted record.
When the ground moves under a pending question (a route change, a signed-out session, a window closing), dismissConfirms() resolves every outstanding call to false and tears the host down. Because it destroys the dialog rather than closing it, it also hands focus back to whatever opened it before unmounting: a router guard must not leave a keyboard user on <body> at the top of the next page.
import { dismissConfirms } from '@nubisco/ui'
router.beforeEach(() => {
dismissConfirms()
})Two questions at once, and one that outlives its dialog
There is one dialog for the whole page. A second confirm() while one is open does not stack a second scrim: it waits, and appears once the first is answered, after the first has finished leaving so it does not read as the first one changing its mind.
The harder case is a request that outlives the dialog that started it. The user confirms a slow delete, a router guard calls dismissConfirms(), the next screen asks a different question, and only then does the first request land. Every request is therefore owned by the question that asked it, and by the attempt that asked it:
- A reply from a request whose dialog is gone resolves nothing and closes nothing.
- A rejection from a dead request is not printed into the dialog on screen, because it is about a record that dialog never mentioned.
- A retry after a timeout supersedes the attempt before it, so the abandoned first request cannot answer the question the second one is still working on.
None of this cancels the work itself: pass an AbortSignal inside onConfirm if the request has to stop too. What it guarantees is narrower and is the whole reason this component exists: nothing is ever confirmed that the user did not confirm.
Using the component directly
Reach for the component when the dialog's open state already belongs to something else: a route, a store, a wizard step that has to survive a reload.
<template>
<NbConfirm
:open="pending !== null"
title="Delete comment"
subject-label="Author"
:subject="pending?.author"
confirm-label="Delete comment"
:busy="deleting"
:error="failure"
@cancel="pending = null"
@confirm="remove"
>
<p>{{ pending?.body }}</p>
</NbConfirm>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const pending = ref(null)
const deleting = ref(false)
const failure = ref('')
async function remove() {
deleting.value = true
failure.value = ''
try {
await api.deleteComment(pending.value.id)
pending.value = null
} catch (error) {
failure.value = error.message
} finally {
deleting.value = false
}
}
</script>You then own the busy and error props, which is the whole difference: useConfirm() exists so that most call sites do not have to.
Tone
tone is danger by default, because the confirmations we audited are overwhelmingly destructive and an opt-in safety default is one every call site can forget. Use tone="neutral" for a consequential but non-destructive commit: publishing a release, transferring ownership, sending an invite. The order, the focus rules and the dismissal rules do not change; only the confirm button's emphasis and the header icon do.
Accessibility
- The dialog is
aria-modal="true"(fromNbModal).NbConfirmsetsrole="alertdialog"for thedangertone and leavesrole="dialog"forneutral: a destructive confirm interrupts rather than being navigated to, andalertdialogis what makes a screen reader read the description on entry instead of waiting to be asked. aria-labelledbypoints at the title andaria-describedbyat the description (the message, the slot content and the subject strip), so entering announces the action and the record rather than the word "dialog". A title-only confirm sets noaria-describedbyat all rather than naming an empty element.- Initial focus is the cancel button, never the confirm button and never the type-to-confirm field.
- Tab and Shift+Tab cycle within the dialog. Focus that leaves it any other way (a click on the page behind the scrim, a
focus()call from application code, a control being disabled under the caret) is recovered onfocusout, not only on a keystroke. - While
busythe dialog has no focusable control at all, because cancel, confirm, the gate field and the close button are disabled together. A browser does not hold focus on a control it has just disabled: it drops it on<body>, outside the dialog, where Esc does nothing and a screen reader is reading the page behind the scrim.NbModalcarriestabindex="-1"on the dialog box for exactly this, andNbConfirmmoves focus there as the pending state begins, and again if anything knocks it out. When the work comes back with a failure, focus moves to the failure itself, which is the sentence the user has to read before deciding whether to retry. - A popup that one of the dialog's own controls teleports to
<body>(NbSelect's list,NbMenu,NbDatePicker's calendar,NbUserMenu's panel) is treated as part of the dialog, so opening one inside the body slot does not get focus yanked back out of it. Anything else opts in three ways: adata-nb-confirm-floatingattribute on the teleported root, thefloatingSelectorsprop for one dialog, orregisterConfirmFloatingSelector('.their-dropdown')once at application start for a third-party popup whose markup you do not own. - The tab cycle knows about more than buttons and inputs, because the body slot takes arbitrary content:
iframe,[contenteditable],audio[controls],video[controls],area[href],summaryand anything with a positivetabindexare all stops, while[tabindex="-1"],[hidden]and[aria-hidden="true"]are not. A preview of the record being deleted is often one of those, and a cycle that does not know about it breaks at the dialog edge. - A drag that starts inside the dialog (selecting the record's name to copy it) and releases over the scrim does not cancel. By the DOM's reckoning that is a click on the overlay; by the user's it is not a click outside anything.
- Esc is answered here and stopped here, in the capture phase, but only when this dialog is the last
aria-modalsurface in the document, which for teleported overlays is the one painted on top. An applicationNbModalunderneath does not also close, and neither does an olderNbConfirmwhen two are open at once. Whilebusy, nothing closes at all. - Esc pressed inside one of those teleported popups closes the popup, not the dialog: the floating-menu test runs before the Esc branch, not after it.
- Focus returns to the element that was focused before the dialog opened, so a delete launched from a row menu returns you to that row. It returns on
dismissConfirms()too, which destroys the dialog rather than closing it. - The pending state sets
aria-busy="true"on the dialog and rendersbusyLabelin arole="status"region, because a button that has quietly become disabled announces nothing. That region, the finished beat and the failure all sit outside the elementaria-describedbypoints at. Analertdialogannounces its description on entry, and text inserted into that same subtree a moment later is at the mercy of how a given screen reader merges the two; kept separate, each of these is an ordinary polite update to a region that was empty, which is the case assistive technology handles the same way everywhere. Which of them is spoken, and how promptly, is still the user's screen reader's decision and not something this library can assert. - Failures render in
NbMessagewithvariant="error", and focus moves to the failure, so it is read even if the live region is not. - The confirm button is disabled, not hidden, until a
typeToConfirmphrase matches, and the field's label states the phrase verbatim. - A body long enough to scroll (slot content at the default
smsize) becomesrole="region"withtabindex="0", labelled by the title, so it can be scrolled from the keyboard.NbModaldrives that decision from aResizeObserveron the content box plus aMutationObserverover its contents, so a body that starts out fitting and stops (the window is resized, slot content arrives from a fetch, a translated string wraps onto another line) is caught too. It is marked only while it actually overflows, so a two-line confirm does not grow a stop in its tab order. - Under
prefers-reduced-motion: reducethe enter and leave transitions run for zero milliseconds. It is written as a duration rather thantransition: nonefor two reasons: the preference can be turned back off while the dialog is open and anoneleft on a recycled node cannot be undone, and the same number is what the queue waits before showing the next question, so a user who asked for less motion is not made to wait out an animation that never ran. Nothing else here moves.
What this fixed in NbModal
NbConfirm renders a real NbModal. Four of that component's behaviours were wrong for a confirmation, and wrong for every other nested dialog in the fleet too, so they were fixed in NbModal itself rather than patched from outside it. NbConfirm configures them with props and does not reach into NbModal's DOM.
| Was | Now | What NbConfirm passes |
|---|---|---|
Every open instance answered Esc on document | Only the last aria-modal surface in the document answers, and closeOnEscape switches it off per instance | :close-on-escape="false", it answers itself |
body { overflow } was cleared on every close | The page-scroll lock is counted across instances and restores the value the page had, rather than blanking it | nothing, it is automatic |
| The close control was always enabled | closeDisabled makes it disabled and aria-disabled | :close-disabled="busy" |
| The content box scrolled with no keyboard way in | A ResizeObserver and a MutationObserver mark it tabindex="0" role="region" whenever it actually overflows | nothing, it is automatic |
| The dialog box could not be named or described | role, labelledBy, describedBy and busy props, plus tabindex="-1" so focus always has a holder | all four |
Every part of it is additive. An NbModal that passes none of the new props behaves as it did, except that it now answers Esc only when it is on top and no longer takes the scroll lock away from a dialog underneath it.