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 itBbPopover: the panel is anchored to the control that opened itBbDropdown: the anchored content is a menu of actionsBbConfirm: 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
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<p class="m-0 text-xs opacity-70">
Edit Marta, cancel, then edit Lukas. His job title stays empty.
</p>
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="member in members"
:key="member.id"
class="flex items-center justify-between gap-3 rounded-(--bb-radius) border p-3"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium">
{{ member.fullName }}
</span>
<span class="block truncate text-xs opacity-70">
{{ member.jobTitle || 'No job title' }}
</span>
</span>
<BbButton
prepend:icon="lucide:pencil"
size="sm"
variant="outline"
@click="onEdit(member)"
>
Edit
</BbButton>
</li>
</ul>
<BbDialog
v-model="open"
:title="`Edit ${editing?.fullName ?? ''}`"
@hidden="onHidden"
@show="onShow"
>
<div class="grid gap-3">
<BbTextInput
id="dialog-draft-name"
v-model="draft.fullName"
compact
label="Full name"
name="fullName"
/>
<BbTextInput
id="dialog-draft-title"
v-model="draft.jobTitle"
compact
label="Job title"
name="jobTitle"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<!-- Cancel is never gated on validity. -->
<BbButton variant="ghost" @click="open = false">Cancel</BbButton>
<BbButton
:disabled="!draft.fullName.trim()"
variant="primary"
@click="onSave"
>
Save
</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton, BbDialog, BbTextInput } from 'bitboss-ui';
interface Member {
id: number;
fullName: string;
// Optional, which is where drafts leak: the key is simply absent.
jobTitle?: string;
}
// In an application these come from your store or your API.
const members = reactive<Member[]>([
{ id: 1, fullName: 'Marta Villoresi', jobTitle: 'Head of Product' },
{ id: 2, fullName: 'Lukas Brandt' },
]);
const open = ref(false);
const editing = ref<Member | null>(null);
// One factory, one shape. Every field the form binds is listed here, so the
// draft can never be missing a key that a record does not fill.
const blank = () => ({ fullName: '', jobTitle: '' });
// Replace the object, never merge into it. Object.assign(draft, member) would
// leave Marta's job title sitting in the field while you edit Lukas.
const draft = ref(blank());
const onEdit = (member: Member) => {
editing.value = member;
open.value = true;
};
const onShow = () => {
const member = editing.value;
draft.value = {
...blank(),
fullName: member?.fullName ?? '',
jobTitle: member?.jobTitle ?? '',
};
};
const onHidden = () => {
editing.value = null;
draft.value = blank();
};
const onSave = () => {
if (editing.value) Object.assign(editing.value, draft.value);
open.value = false;
};
</script>
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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">
Review recipients
</BbButton>
<BbDialog v-model="open" size="md" title="Recipients">
<!-- The body is the only part that scrolls. -->
<template #default="{ close }">
<ul class="m-0 grid list-none gap-2 p-0 text-sm">
<li
v-for="person in recipients"
:key="person.id"
class="flex justify-between gap-4"
>
<span>{{ person.fullName }}</span>
<span class="opacity-70">{{ person.email }}</span>
</li>
</ul>
<BbButton class="mt-3" size="sm" variant="ghost" @click="close">
Close from the body
</BbButton>
</template>
<!-- The footer stays pinned under the scroll area. One primary action. -->
<template #footer>
<div class="flex justify-end gap-2">
<BbButton variant="ghost" @click="open = false">Cancel</BbButton>
<BbButton variant="primary" @click="open = false">
Send to {{ recipients.length }}
</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog } from 'bitboss-ui';
import { users } from '~/demo-data';
const open = ref(false);
// Long enough that the body scrolls while the header and footer stay put.
const recipients = users;
</script>
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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">Export orders</BbButton>
<!--
`aria-describedby` is no longer wired for you: the v2 `description` prop
and its slot are gone, so point it at your own copy by id.
-->
<BbDialog v-model="open" aria-describedby="dialog-export-note">
<template #header="{ titleId, close }">
<!-- flex-1, or the block shrinks to its text and hugs the left edge. -->
<div class="min-w-0 flex-1">
<h2 :id="titleId" class="m-0 text-base font-medium">Export orders</h2>
<p class="m-0 text-xs opacity-70">CSV, one row per order line</p>
</div>
<!-- The slot replaces the default ×, so you own the close control. -->
<BbButton icon="lucide:x" size="sm" variant="ghost" @click="close">
Close
</BbButton>
</template>
<p id="dialog-export-note" class="m-0 text-sm">
{{ orders.length }} orders match the current filters. The file is
prepared in the background and emailed to you when it is ready.
</p>
<template #footer>
<div class="flex justify-end">
<BbButton variant="primary" @click="open = false">Export</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog } from 'bitboss-ui';
import { orders } from '~/demo-data';
const open = ref(false);
</script>
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.
<template>
<div class="flex flex-wrap items-start gap-3">
<BbButton variant="outline" @click="firstField = true">
Default focus
</BbButton>
<!-- No focus-target: the browser focuses the first tabbable element. -->
<BbDialog v-model="firstField" title="Rename workspace">
<BbTextInput
id="dialog-focus-default"
v-model="nameA"
compact
label="Workspace name"
name="workspaceNameDefault"
/>
<template #footer>
<div class="flex justify-end">
<BbButton variant="primary" @click="firstField = false">
Rename
</BbButton>
</div>
</template>
</BbDialog>
<BbButton variant="outline" @click="chosen = true">
Focus the primary action
</BbButton>
<!--
A CSS selector resolved inside the dialog. Point it at what the reader
is most likely to press, not at what happens to come first.
-->
<BbDialog
v-model="chosen"
focus-target="#dialog-focus-confirm"
title="Rename workspace"
>
<BbTextInput
id="dialog-focus-chosen"
v-model="nameB"
compact
label="Workspace name"
name="workspaceNameChosen"
/>
<template #footer>
<div class="flex justify-end">
<BbButton
id="dialog-focus-confirm"
variant="primary"
@click="chosen = false"
>
Rename
</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbTextInput } from 'bitboss-ui';
const firstField = ref(false);
const chosen = ref(false);
const nameA = ref('Vantera');
const nameB = ref('Vantera');
</script>
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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">Accept terms</BbButton>
<!--
persistent alone still leaves the × working, so hide-close goes with it —
and then the footer must carry a visible way out.
-->
<BbDialog
v-model="open"
hide-close
persistent
title="Updated terms of service"
>
<p class="m-0 text-sm">
We changed how long we keep order data. Accept to continue, or decline
and keep using the read-only view.
</p>
<template #footer>
<div class="flex justify-end gap-2">
<BbButton variant="ghost" @click="decide('Declined')">
Decline
</BbButton>
<BbButton variant="primary" @click="decide('Accepted')">
Accept
</BbButton>
</div>
</template>
</BbDialog>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog } from 'bitboss-ui';
const open = ref(false);
const status = ref('Backdrop and Escape play a nudge instead of closing.');
function decide(answer: string) {
status.value = `${answer}.`;
open.value = false;
}
</script>
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
<template>
<div class="flex flex-col items-start gap-3">
<p class="m-0 text-sm">
Project · <strong>{{ projectName }}</strong>
</p>
<BbButton variant="outline" @click="open = true">Rename project</BbButton>
<!-- `disabled` guards dismissal only. Everything inside stays live. -->
<BbDialog
v-model="open"
:disabled="saving"
title="Rename project"
@show="seed"
>
<BbTextInput
id="dialog-busy-name"
v-model="draft"
compact
:disabled="saving"
label="Project name"
name="projectName"
/>
<template #footer>
<div class="flex justify-end gap-2">
<!-- So your own controls need disabling too. -->
<BbButton :disabled="saving" variant="ghost" @click="open = false">
Cancel
</BbButton>
<!-- The async handler drives BbButton's own loading state. -->
<BbButton :disabled="!draft.trim()" variant="primary" @click="save">
Rename
</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
const projectName = ref('acme-storefront');
const open = ref(false);
const draft = ref('');
const saving = ref(false);
function seed() {
draft.value = projectName.value;
saving.value = false;
}
async function save() {
saving.value = true;
try {
await delay(null, 1200);
projectName.value = draft.value.trim();
// Do the work first, then close: the transition runs over your refresh.
open.value = false;
} finally {
saving.value = false;
}
}
</script>
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:
- <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.
<template>
<div class="flex flex-wrap items-start gap-2">
<BbButton
v-for="option in presets"
:key="option.size"
variant="outline"
@click="active = option.size"
>
{{ option.size }} · {{ option.width }}
</BbButton>
<BbDialog
:model-value="active !== null"
:size="active ?? 'sm'"
:title="`Width preset ${active}`"
@update:model-value="onPresetToggle"
>
<p class="m-0 text-sm">
`size` caps the width. Height always hugs the content, so a short dialog
stays short.
</p>
</BbDialog>
<BbButton variant="outline" @click="responsive = true">
per-breakpoint map
</BbButton>
<!-- Only the map form re-resolves on a resize; a bare size is read once. -->
<BbDialog
v-model="responsive"
:size="{ default: 'xs', lg: 'xl' }"
title="Narrow on phones, roomy from lg up"
>
<p class="m-0 text-sm">
Resize the window across the <code>lg</code> breakpoint with this open
and the width follows.
</p>
</BbDialog>
<BbButton variant="outline" @click="full = true">fullscreen</BbButton>
<!-- Fullscreen ignores `size` and fills the viewport minus the page margin. -->
<BbDialog v-model="full" fullscreen title="Fullscreen">
<p class="m-0 text-sm">
Deliberately not edge to edge — the standard page margin stays.
</p>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog } from 'bitboss-ui';
type Preset = 'xs' | 'sm' | 'md' | '2xl';
// The v3 defaults, from `config.dialogDefaultSizes`.
const presets: Array<{ size: Preset; width: string }> = [
{ size: 'xs', width: '320px' },
{ size: 'sm', width: '384px' },
{ size: 'md', width: '448px' },
{ size: '2xl', width: '672px' },
];
const active = ref<Preset | null>(null);
const responsive = ref(false);
const full = ref(false);
function onPresetToggle(value: boolean) {
if (!value) active.value = null;
}
</script>
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:
// 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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="settings = true">
Storage settings
</BbButton>
<!-- Same `stack` name: opening the second hides the first, it does not layer. -->
<BbDialog v-model="settings" stack="storage" title="Storage">
<div class="grid gap-3">
<BbSwitch
id="dialog-stack-backups"
v-model="autoBackup"
label="Nightly backups"
name="autoBackup"
/>
<BbButton
append:icon="lucide:chevron-right"
variant="outline"
@click="retention = true"
>
Advanced retention
</BbButton>
</div>
</BbDialog>
<BbDialog v-model="retention" stack="storage" title="Advanced retention">
<BbSwitch
id="dialog-stack-monthly"
v-model="keepMonthly"
label="Keep monthly snapshots for a year"
name="keepMonthly"
/>
<template #footer>
<div class="flex justify-end">
<!-- Closing this one brings the first back. -->
<BbButton variant="primary" @click="retention = false">Back</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbSwitch } from 'bitboss-ui';
const settings = ref(false);
const retention = ref(false);
const autoBackup = ref(true);
const keepMonthly = ref(false);
</script>
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
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">Order detail</BbButton>
<!--
Nothing here asks for a sheet. Below 768px `adaptive` renders this as a
BbOffCanvas instead of a centered modal, and `off-canvas-props` tunes
only that side of it.
-->
<BbDialog
v-model="open"
:off-canvas-props="{ draggable: true }"
size="md"
title="Order 4812"
>
<dl class="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="m-0 opacity-70">Customer</dt>
<dd class="m-0">Northwind Labs</dd>
<dt class="m-0 opacity-70">Placed</dt>
<dd class="m-0">14 March</dd>
<dt class="m-0 opacity-70">Total</dt>
<dd class="m-0">€ 1.240,00</dd>
</dl>
<template #footer>
<div class="flex justify-end">
<BbButton variant="primary" @click="open = false">Close</BbButton>
</div>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog } from 'bitboss-ui';
const open = ref(false);
</script>
: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:
<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:
- <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.
/* 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:
.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.