Skip to content

BbDialog

The centred modal: a native dialog over the page, a bottom sheet on phones.

import { BbDialog } from 'bitboss-ui';

On this page

Use it for

Reach for BbDialog when someone has to finish one focused task without losing the page behind it. An edit form, a create step, a decision that has to be made now. Nothing else on the page is reachable while it is open.

Use something else when

  • BbOffCanvas: the panel sits beside the task instead of replacing it
  • BbPopover: the panel is anchored to the control that opened it
  • BbDropdown: the anchored content is a menu of actions
  • BbConfirm: it is a plain yes/no, above all a destructive one

Also see: Validated forms.

BbDialog is the library's modal: give it an id and a title, and put v-bb-dialog on the button that opens it.

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.

Open by id

Most dialogs need only a button that opens them, so they need no ref(false), no @click and no v-model. The value of the directive is the dialog's id, in inner quotes:

vue
<BbButton v-bb-dialog="'invite'">Invite teammate</BbButton>

<BbDialog id="invite" title="Invite teammate">
    …
    <template #footer="{ close }">
        <BbButton variant="ghost" @click="close">Cancel</BbButton>
    </template>
</BbDialog>

When the dialog needs to know what it was opened for, pass that as data: v-bb-dialog="{ id: 'edit-member', data: member }". Every row opens the same dialog with its own data. Write one <BbDialog id> per id, never one per row: the id is also the element's DOM id, and two elements with one id are broken HTML. The dialog reads data in two places:

  • the default, header and footer slots receive it as data, typed any, so data.fullName reads with no cast;
  • useBbDialogContext<T>(id) returns it typed, along with open, isReady, show(data?), hide(), complete(value) and reset(). A component inside the dialog can call it too.

data is the opening's own copy of what you passed (see the edit form below), and it stays set until the close transition has run, so the content never flashes empty as the dialog fades out.

  • Three closes. complete(value) closes with a result for whoever opened the dialog. The context's hide() is the code's close, with no result. Both close even under prevent-close, so call one when an operation is done. The slots' close() is the user's close, like the ×: wire it to Cancel.
  • A show() on an open dialog changes nothing. It joins the opening already showing (its promise, its data); the new data, trigger and onComplete are ignored. A show during the close transition opens it again.
  • Lookup starts at the trigger. The directive looks for the dialog in the component whose template it sits in and in what that component renders, then in its parents. A dialog wrapped in a component of its own, such as <EditMemberDialog />, opens from the page that renders it, and two pages can each have an id="edit".
  • v-model still works, and wins over the directive. Keep it for a dialog your state opens, from a route or a watcher, rather than from a button.
  • The button gets aria-haspopup="dialog", aria-expanded and aria-controls, and focus returns to it on close. On a phone the adaptive sheet opens the same way.

npx bitboss-ui check and the ESLint rule bitboss-ui/portal-names report an id that no <BbDialog id> in the project declares (unknown-dialog).

A dialog in the URL

With navigation, opening the dialog writes ?dialog=true to the URL (or the key you name with query-key), and closing it takes the key away.

  • Back closes it, like Escape: the result's reason is 'escape', and a dialog that refuses Escape refuses Back too.
  • A link with the key opens it on load, so a support email can point at it.
  • replace swaps the history entry instead of adding one: Back then leaves the page.
  • Two URL dialogs on one page each need their own query-key. A BbOffCanvas writes offcanvas by default, so a dialog and a panel never collide.

Editing a record

Each row's Edit button hands the dialog its record as data, and the dialog gets its own copy of it, made fresh on every opening. So the form binds data directly, with no draft to seed and nothing to clear: Save hands the saved record back with complete, and any other close throws the copy away.

Edit Marta, cancel, then edit her again: the cancelled change is gone.

  • Marta VilloresiHead of Product
  • Lukas BrandtNo job title

Always pass title. It draws the header and gives the dialog its accessible name; without one a screen reader announces "dialog" and nothing else.

  • The row stays untouched until Save. Plain objects and arrays are copied all the way down; anything else (a Date, a File, a class instance) is the object you passed.
  • Compare by id, never by identity. data is not the row you passed, so onComplete (or the code after await show()) is where the page writes the result back, as replace does above.
  • While the save runs, the form is read-only and the dialog refuses every close until it settles (Controlling dismissal).

Create and edit in one dialog

A create has no row to pass, so data would be undefined. Give the dialog a default-data object: every opening without data gets a fresh copy of it, and an edit lays its row over it.

  • Send the Q3 invoices · urgent
  • Renew the domain
  • The fields bind data.title with no guard, on a create and an edit alike.
  • reset() (in the slots, or useBbDialogContext(id).reset()) restores the keys default-data declares from a fresh copy and keeps every other key: Clear empties a task, and on an edit the row's id stays, so the cleared task still saves as an edit. It never goes back to the row's values; Cancel is what discards an edit.
  • Every close resets it as well, so nothing needs doing on @show.

The body is lazy: it renders when the dialog opens and unmounts after the close transition, so every opening starts fresh, the state of what is inside it included. A reopen during the close transition gets a fresh body too. eager keeps it mounted from the start and across closes, with data back to undefined while it is closed; use it only when a test or measurement must read the body before the first open. @hidden fires after the close transition, the place to tear down expensive content.

What the opener gets back

Every opening ends one way or the other. onComplete on the directive hears only a complete(value). For the rest, open from code: show() returns a promise of { completed: true, value } or { completed: false, reason }.

Pick a format, or close the dialog any other way.

Pick a format, then try Escape, the backdrop, the × and Cancel: each names its reason ('escape', 'backdrop', 'close'). hide() and a v-model set to false say 'hide'.

@hide carries the same result as the close starts, so the dialog's own listener knows a save from a dismissal. A live preview reverts on a dismissal with no flag of its own:

  • It settles when the close starts, so the page can update while the dialog fades out. It never rejects and never hangs.
  • Close a save with complete(), not by writing the model, to tell it from a cancel. On a v-model dialog too, it closes through the model and reports { completed: true }.
  • A click that awaits the dialog spins like any async click: put disable-auto-loading on that button. onComplete never spins.
  • Prefer the directive on a button. show() is for opens no button makes, or a flow that needs the reason; the directive keeps the button's aria-haspopup, aria-expanded and aria-controls.
  • useBbDialogContext<In, Out> types both ends; untyped, they are any.

The page saves

When the page owns the save (it knows the endpoint and the list to update), onComplete returns the request's promise. complete(value) waits for it, and the dialog stays open and locked until it settles: the ×, Cancel, Escape and the backdrop are refused, and the footer's Save spins.

Rename Harbor to Atlas: the server refuses, and the dialog stays open with the message under the field.

  • Harbor
  • Atlas
  • Resolved, the dialog closes and the opening settles { completed: true, value }.
  • Rejected, the dialog stays open with the user's edit, and complete() rejects with the same error. Bind the error's fields on the form with :field-errors: each message shows under its field until the user edits it.
  • Catch complete() whenever an opener may reject, as the form above does. Uncaught, the rejection travels through the form's @submit to app.config.errorHandler.
  • From code it is edit.show(row, { onComplete: save }), on an id handle and a component handle alike. A synchronous onComplete, or none, closes at once.
  • onComplete is just a function. A trigger inside a child component gets the page's save the way your app already shares things: a function prop (:save="saveMember"), a page provide / inject, or a store action. Not an emit: an emit cannot return the promise.

An Inertia visit returns no promise and never rejects on its own, so wrap it: a validation error rejects with the errors, success resolves, and a visit cut short by another visit resolves too, closing the dialog.

ts
import { router } from '@inertiajs/vue3';

const save = (user: User) =>
    new Promise<void>((resolve, reject) => {
        router.put(`/users/${user.id}`, user, {
            onSuccess: () => resolve(),
            onError: (errors) => reject({ errors }),
            // Cancelled or interrupted: close. A 500 or a network error
            // keeps the dialog open.
            onFinish: (visit) =>
                visit.cancelled || visit.interrupted
                    ? resolve()
                    : reject(new Error('The save failed')),
        });
    });

Opening a component

A create flow does not need its dialog mounted on the page. Hand the directive the dialog's component instead of an id: the app's <BbHost /> mounts a fresh instance for that one opening and unmounts it after the close, so every open starts from a new setup and there is nothing to reset.

BugDesign

The component renders its own <BbDialog> and binds its form to the opening's data (undefined here: the form starts from its defaults). From code it is useBbDialogContext<In, Out>(NewLabelDialog).show(data).

  • It needs <BbHost /> at the app root, the one that renders confirms and toasts. Without it the opening settles 'unmounted' after a second.
  • It takes the trigger's surroundings: a provide or a <BbPtScope> around the button reaches the dialog, which paints in the host.
  • It lives as long as its opener. A route change or a row that re-renders away dismisses it ('unmounted').
  • One handle, one dialog. useBbDialogContext<In, Out>(NewLabelDialog) is the same handle as the id form (isReady, open, data, show, hide, complete, reset), about its own dialog: a show() while it is open joins it. Another handle, or another trigger button, opens its own instance on top.
  • From a store. Create the handle in a setup store's body, then call show() from its actions or a router hook: the opening lives until it closes.

Structure and size

The body is the only one of the three regions that scrolls: header and footer stay pinned.

Put actions in the footer slot, one primary with its counterweight beside it; its scope carries close, so Cancel needs no handle on the dialog. The footer lays the buttons out in a row at the end by itself, so they need no wrapper; class="me-auto" on one pushes it to the start. A footer with two primaries is a decision you have not made yet. Use the title slot when only the title text needs restyling. With neither a title nor a description the whole header goes, leaving the × floating over the top corner of the body. description adds one muted line under the title, and it is what screen readers read as the dialog's description. When that line needs markup, use the description slot.

The header slot replaces the whole header, on the dialog and on the mobile sheet alike.

Two things this slot makes yours. Render a control wired to the slot's close, because the default × is gone. And put titleId on your heading, or the dialog has no accessible name while the markup still looks correct. The description prop does not render inside a replaced header. Give your slot root flex-1: the header is a flex row, so a single child otherwise hugs the left edge.

Coming from v2

#close is gone, a custom close control lives inside #header. hideHeader is gone: omit title. compact, overlayClasses and panelClasses are gone too, since spacing is token-driven and the panel takes a normal class.

Never gate Cancel on validity: an unfinished form is exactly when someone wants out. A submit button in the footer sits outside the <form> element. Give the form an id and point the button at it with form="…" and type="submit", as Driving the form from outside shows.

Width and fullscreen

size is a maximum width: the height always hugs the content. Besides the presets it takes a pixel number or a CSS length.

The presets run from xs (320px) to 2xl (672px), and sm (384px) is the default. A per-breakpoint map, :size="{ default: 'xs', lg: 'xl' }", is the only form that reacts to a live resize.

Coming from v2preset widths

The preset values changed between v2 and v3 and nothing warns you. v2 shipped { sm: 384, md: 652, lg: 896 }, so a size="md" dialog now loses about 200px. Pin the old numbers if your layouts assumed them:

ts
// nuxt.config.ts
export default defineNuxtConfig({
    bitboss: { dialogDefaultSizes: { md: 652, lg: 896 } },
});

fullscreen fills the viewport minus the standard page margin, intentionally not edge to edge. fullscreen="mobile" applies that only on small viewports.

Focus and the top layer

Focus, Escape and layering are the browser's job. There is nothing to wire, not even for a popover or a select opened from in here.

Focus is on: nothing yet, open one of the dialogs

focus-target is a CSS selector and decides only where focus starts. Without it the browser focuses the first tabbable element, and in a dialog with a header that is the ×. Point it at the field in a form, and at the primary action in a decision whose real content is its footer.

Focus returns to the trigger on close, as long as that trigger still exists. When the dialog's action destroys the row that opened it, focus drops to <body>, so move it somewhere sensible yourself.

The dialog paints above every z-index, and it stays where you declared it in the DOM. Scoped styles, provide/inject and your component tree all behave normally.

Steps in one dialog

  • Each step's first field gets autofocus: it fires when the field mounts, so focus follows the step with no watcher (focus-target picks only where the opening starts).
  • A final success step with no field is a status region (role="status"), announced as it appears, not a focus target.
  • A footer button that changes type in place (Next, type="button", becoming Accept, type="submit", during its own click): give each its own key. Unkeyed, Vue reuses the element, its type flips mid-click, and the click on Next submits the form.

Controlling dismissal

persistent stops the backdrop and Escape from closing. The × still closes it: that is the deliberate escape hatch.

Backdrop and Escape play a nudge instead of closing.

Pair it with hide-close when the choice really has to be made, and then the footer owes the user a visible way out. persistent, hide-close and no Cancel is a trap in three directions: no Escape for the keyboard, no backdrop for the pointer, nothing for a screen reader to find.

While a save is in flight the dialog has to stay put, and a BbForm inside it does that by itself: return the save's promise from @submit and, until it settles, the fields turn read-only and the dialog refuses every close, the ×, Cancel, Escape and the backdrop, with the same nudge.

Project · vantera-storefront

No saving ref, no :disabled, no prevent-close, and no :loading on the footer's Rename: it sits outside the form, so type="submit" and form="dialog-busy-form" submit it, and it spins with the save on its own. complete(await save(data)) closes once the save resolves: complete and the context's hide() (or a v-model) close under the lock, the slots' close() does not.

prevent-close is for the rest. Unset, it follows the form. true refuses every user close, the × and the slots' close included, for work that is not a form submit. false keeps the dialog closable even mid-save, and disable-submit-lock on the form does the same.

Coming from v2show-close → hide-close

v2's show-close (default true) is v3's hide-close (default false), and the compiler cannot see it:

diff
- <BbDialog v-model="open" :show-close="false" persistent />
+ <BbDialog v-model="open" hide-close persistent />

A leftover :show-close="false" falls through to $attrs, and the close button comes back on the dialog that was deliberately built without one. eslint --fix rewrites it.

Stacks and mobile sheets

Dialogs that share a stack name replace one another instead of piling up.

Only the most recently opened member is visible, and closing it brings the previous one back. A two-step flow then reads as one surface whose content swaps. A member adds no dim of its own: the one it covers keeps its backdrop, so the page behind stays as dark as under the first member. Dialogs with different names layer by open order, each adding its dim (--bb-overlay-above; transparent keeps one dim under the whole pile).

Before you reuse the name on a phone: BbOffCanvas's stack does the opposite, with parents stepping back and leaving a band visible. An adaptive dialog forwards stack to its mobile sheet, so the same name gives you replacement on desktop and peeking on mobile. Override it through off-canvas-props.

The mobile bottom sheet

Below 768px this is not a centred modal: it renders as a BbOffCanvas bottom sheet. That is the shipped behaviour.

390px

:adaptive="false" keeps the centred modal everywhere, and adaptive: false in the plugin options does it project-wide.

The surface is latched when the dialog opens, so rotating a phone never swaps a modal for a sheet under someone's finger. snap-points and v-model:snap reach the sheet too (snap points).

The sheet starts bottom-anchored, draggable and content-sized. The dialog's own size is deliberately not forwarded: a max width and a drawer's extent are different measurements. Tune the sheet with off-canvas-props, which merges last:

vue
<BbDialog
    id="order-detail"
    :off-canvas-props="{ side: 'right', draggable: false, size: 'lg' }"
    title="Order detail"
/>

What you get on a phone is a real BbOffCanvas, so its rules apply there: the drag gesture, the peeking stack, and size meaning extent rather than width. The drawer page is where those live.

Padding and the title

Spacing reaches the dialog through --bb-panel-p (16px), split into the local pair --px and --py on .bb-dialog. Set them on the component's class, never on :root.

css
.my-dialog {
    --px: 24px;
    --py: 10px;
}

For per-part differences, scope --py to the part: .bb-dialog__header, .bb-dialog__content, .bb-dialog__footer. On a phone those parts are .bb-offcanvas__*.

To style both surfaces at once use pt (pt:header, pt:content, pt:footer…). It follows the dialog onto the sheet, and pt:sheet targets the phone only. Three more locals set the rhythm and the title for every dialog and sheet at once: --dialog-gap is the body-to-footer rhythm, --dialog-title-fs the title's size and --dialog-title-fw its weight (500).

css
.my-theme {
    --dialog-title-fw: 600;
}

BbOffCanvas uses the same names on its own parts.

Coming from v2--bb-dialog-close

Delete --bb-dialog-close rather than porting it. In v2 it was the close icon's width; in v3 --close-size (28px) is the whole control box, and the title reserves the same space beside it. A copied 12px then gives you a 12×12px target, under the 24px minimum in WCAG 2.5.8. The correct edit is usually none: v3 already ships the same 28px control v2 produced. The rest of the --bb-dialog-* family went with it: --bb-dialog-px and --bb-dialog-py are the --px and --py above, and everything else is plain CSS on the .bb-dialog__* parts.