Skip to content

BbDialog

The centered 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

Pass Through

Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.

A dialog with two buttons in the footer is not a question. That is a hand-built confirm you now have to keep accessible yourself.

Forms and drafts

Bind a boolean to v-model, reseed the draft on @show, and clear it on @hidden. There is no open() to call.

Edit Marta, cancel, then edit Lukas. His job title stays empty.

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

Replace the draft object instead of merging into it. Object.assign leaves old optional fields behind when the next record omits them. Build from a blank factory on every open so Cancel is a real discard and records never share state.

@hidden fires after the close transition. Clear the draft there and tear down expensive content. The body is lazy; use eager only when a test or measurement must read it before the first open.

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. A footer with two primaries is a decision you have not made yet. Use the title slot when only the title text needs restyling. Omit title and the whole header goes with it, leaving the × floating over the top corner of the body.

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. Give your slot root flex-1: the header is a flex row, so a single child otherwise hugs the left edge.

Coming from v2

description and its #description slot are gone, and so is the aria-describedby they wired. Put the copy in the body and set the attribute yourself if you need it (attributes fall through to the <dialog> element). #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 drive it with useBbFormContext, the way Driving the form from outside shows.

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-target is a CSS selector and decides only where focus starts. Without it the first tabbable element gets it, which is right for a form and wrong for a decision. In a dialog whose real content is its footer, point it at the primary action.

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.

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.

disabled is the same guard while the surface is busy, and it blocks the × as well.

Project · acme-storefront

It guards dismissal only. The inputs and buttons inside stay live, so disable them yourself while the work is in flight. Closing through v-model keeps working under both props, which is how the demo above closes itself.

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.

Width and fullscreen

size is a maximum width: the height always hugs the content.

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.

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.

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. Dialogs with different names simply layer by open order.

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 centered modal: it renders as a BbOffCanvas bottom sheet. That is the shipped behaviour.

390px

:adaptive="false" keeps the centered 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.

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
    v-model="open"
    :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.

Two silent renames land here. v2 spelled this prop canvas-props on BbDialog and offcanvas-props on every other adaptive component. Inside the object, direction became side:

diff
- <BbDialog :canvas-props="{ direction: 'right' }" />
+ <BbDialog :off-canvas-props="{ side: 'right' }" />

Neither warns, so the failure looks like "the sheet ignores my config" rather than like an error. eslint --fix rewrites both.

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
/* v2: --bb-dialog-px: 24px; --bb-dialog-py: 10px */
.my-dialog {
    --px: 24px;
    --py: 10px;
}

For per-part differences, scope --py to the part: .bb-dialog__header, .bb-dialog__body-content, .bb-dialog__footer. Two more locals stay: --dialog-gap is the body-to-footer rhythm and --dialog-title-fs the title's size. The title's weight is hard-coded three classes deep, so changing it takes three classes:

css
.my-dialog .bb-dialog__header .bb-dialog__title {
    font-weight: 600;
}

Delete --bb-dialog-close rather than porting it. In v2 it was the close icon's width; in v3 --size is the whole control box. 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, replaced by plain CSS on the .bb-dialog__* parts. BbOffCanvas uses the same names on its own parts.