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
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:
<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,headerandfooterslots receive it asdata, typedany, sodata.fullNamereads with no cast; useBbDialogContext<T>(id)returns it typed, along withopen,isReady,show(data?),hide(),complete(value)andreset(). 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'shide()is the code's close, with no result. Both close even underprevent-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 andonCompleteare 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 anid="edit". v-modelstill 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-expandedandaria-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).
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.
<template>
<div class="flex flex-col items-start gap-3">
<!-- Opening writes ?share=true to the URL; Back closes the dialog, and
a link with ?share=true opens it on load. -->
<BbButton v-bb-dialog="'dialog-share'" prepend:icon="lucide:share-2" variant="outline">
Share report
</BbButton>
<BbDialog id="dialog-share" navigation query-key="share" title="Share report">
<p class="m-0 text-sm">
Look at the address bar: the dialog is in the URL. Press Back to close it.
</p>
<template #footer="{ close }">
<BbButton variant="primary" @click="close">Done</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbDialog, vBbDialog } from 'bitboss-ui';
</script>
- Back closes it, like
Escape: the result's reason is'escape', and a dialog that refusesEscaperefuses Back too. - A link with the key opens it on load, so a support email can point at it.
replaceswaps the history entry instead of adding one: Back then leaves the page.- Two URL dialogs on one page each need their own
query-key. ABbOffCanvaswritesoffcanvasby 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
<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 her again: the cancelled change is gone.
</p>
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="person in members"
:key="person.id"
class="flex items-center justify-between gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium">
{{ person.fullName }}
</span>
<span class="block truncate text-xs opacity-70">
{{ person.jobTitle || 'No job title' }}
</span>
</span>
<!-- Every row opens the ONE dialog below with its member; Save
hands the saved member back to `replace`. -->
<BbButton
v-bb-dialog="{ id: 'dialog-edit-member', data: person, onComplete: replace }"
prepend:icon="lucide:pencil"
size="sm"
variant="outline"
>
Edit
</BbButton>
</li>
</ul>
<!-- `data` is this opening's own copy of the member: the form edits it,
closing any other way throws it away. -->
<BbDialog id="dialog-edit-member" v-slot="{ data, complete }" title="Edit member">
<BbForm
class="grid gap-3"
:model="data"
@submit="async () => complete(await save(data))"
>
<BbTextInput compact label="Full name" name="fullName" />
<BbTextInput compact label="Job title" name="jobTitle" />
<BbButton class="justify-self-end" type="submit" variant="primary">
Save
</BbButton>
</BbForm>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbForm, BbTextInput, vBbDialog } from 'bitboss-ui';
import { delay } from '~/demo-data';
type Member = { id: number; fullName: string; jobTitle?: string };
// In an application these come from your store or your API.
const members = ref<Member[]>([
{ id: 1, fullName: 'Marta Villoresi', jobTitle: 'Head of Product' },
{ id: 2, fullName: 'Lukas Brandt' },
]);
// Your API call: resolves the saved record.
const save = (member: Member) =>
delay({ ...member, jobTitle: member.jobTitle?.trim() || undefined }, 600);
const replace = (saved: Member) => {
members.value = members.value.map((m) => (m.id === saved.id ? saved : m));
};
</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.
- The row stays untouched until Save. Plain objects and arrays are copied
all the way down; anything else (a
Date, aFile, a class instance) is the object you passed. - Compare by id, never by identity.
datais not the row you passed, soonComplete(or the code afterawait show()) is where the page writes the result back, asreplacedoes 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
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3"
>
<span class="min-w-0 truncate text-sm">
{{ task.title }}<template v-if="task.urgent"> · urgent</template>
</span>
<!-- An edit: the row is laid over the default. -->
<BbButton
v-bb-dialog="{ id: 'dialog-task', data: task, onComplete: save }"
size="sm"
variant="outline"
>
Edit
</BbButton>
</li>
</ul>
<!-- A create: no data, so `data` is a fresh copy of the default. -->
<BbButton
v-bb-dialog="{ id: 'dialog-task', onComplete: save }"
class="self-start"
prepend:icon="lucide:plus"
variant="outline"
>
New task
</BbButton>
<!-- `data` is never undefined here: the fields bind it with no guard. -->
<BbDialog id="dialog-task" :default-data="NEW_TASK" title="Task">
<template #title="{ data }">
{{ data.id ? 'Edit task' : 'New task' }}
</template>
<template #default="{ data }">
<div class="grid gap-3">
<BbTextInput v-model="data.title" compact label="Title" />
<BbSwitch v-model="data.urgent" label="Urgent" />
</div>
</template>
<template #footer="{ data, close, complete, reset }">
<!-- `reset()` restores the default's keys and keeps the rest: on an
edit the row's id stays, so the cleared task still saves as an edit. -->
<BbButton class="me-auto" variant="ghost" @click="reset">
Clear
</BbButton>
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton
:disabled="!data.title?.trim()"
variant="primary"
@click="complete(data)"
>
Save
</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbSwitch, BbTextInput, vBbDialog } from 'bitboss-ui';
type Task = { id?: number; title: string | null; urgent: boolean };
// A plain object: the dialog copies it for every create.
const NEW_TASK: Task = { title: '', urgent: false };
const tasks = ref<Task[]>([
{ id: 1, title: 'Send the Q3 invoices', urgent: true },
{ id: 2, title: 'Renew the domain', urgent: false },
]);
// An id updates that row, none adds one.
const save = (saved: Task) => {
tasks.value =
saved.id === undefined
? [...tasks.value, { ...saved, id: tasks.value.length + 1 }]
: tasks.value.map((task) => (task.id === saved.id ? saved : task));
};
</script>
- The fields bind
data.titlewith no guard, on a create and an edit alike. reset()(in the slots, oruseBbDialogContext(id).reset()) restores the keysdefault-datadeclares from a fresh copy and keeps every other key: Clear empties a task, and on an edit the row'sidstays, 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.
<template>
<div class="flex flex-col items-start gap-3">
<!-- `disable-auto-loading`: the click awaits the whole dialog, and a
button spinning until it closes would read as busy. -->
<BbButton disable-auto-loading variant="outline" @click="exportOrders">
Export orders
</BbButton>
<p class="m-0 text-sm opacity-70" role="status">
<code>{{ result ? JSON.stringify(result) : 'Pick a format, or close the dialog any other way.' }}</code>
</p>
<BbDialog id="dialog-export-format" title="Export format">
<p class="m-0 text-sm">
{{ orders.length }} orders match the current filters.
</p>
<template #footer="{ close, complete }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton variant="outline" @click="complete('csv')">CSV</BbButton>
<BbButton variant="primary" @click="complete('xlsx')">Excel</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, useBbDialogContext } from 'bitboss-ui';
import type { BbDialogResult } from 'bitboss-ui';
import { orders } from '~/demo-data';
type Format = 'csv' | 'xlsx';
const exportFormat = useBbDialogContext<undefined, Format>('dialog-export-format');
const result = ref<BbDialogResult<Format>>();
// Settles once, when the close starts, and never rejects: a pick is
// `{ completed: true, value }`, any other close names its `reason`.
async function exportOrders() {
result.value = await exportFormat.show();
}
</script>
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:
<template>
<div class="flex items-center gap-3">
<span
aria-hidden="true"
class="size-8 rounded-full border border-(--bb-border)"
:style="{ background: accent }"
/>
<BbButton v-bb-dialog="'dialog-accent'" variant="outline">
Change accent
</BbButton>
<!-- The swatch follows every pick at once. `@hide` gets the opening's
result: any close but Apply puts the old colour back. -->
<BbDialog
id="dialog-accent"
title="Accent colour"
@hide="revert"
@show="remember"
>
<div class="flex flex-wrap gap-2">
<BbButton
v-for="swatch in swatches"
:key="swatch.color"
:aria-pressed="accent === swatch.color"
variant="outline"
@click="accent = swatch.color"
>
{{ swatch.name }}
</BbButton>
</div>
<template #footer="{ close, complete }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton variant="primary" @click="complete()">Apply</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, vBbDialog } from 'bitboss-ui';
import type { BbDialogResult } from 'bitboss-ui';
const swatches = [
{ name: 'Teal', color: '#0f766e' },
{ name: 'Violet', color: '#6d28d9' },
{ name: 'Amber', color: '#b45309' },
];
const accent = ref(swatches[0]!.color);
let before = accent.value;
const remember = () => {
before = accent.value;
};
// A dismissal (Cancel, Escape, the backdrop, the ×) reverts the preview.
const revert = (result: BbDialogResult) => {
if (!result.completed) accent.value = before;
};
</script>
- 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 av-modeldialog too, it closes through the model and reports{ completed: true }. - A click that awaits the dialog spins like any async click: put
disable-auto-loadingon that button.onCompletenever 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'saria-haspopup,aria-expandedandaria-controls. useBbDialogContext<In, Out>types both ends; untyped, they areany.
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
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<p class="m-0 text-xs opacity-70">
Rename Harbor to Atlas: the server refuses, and the dialog stays open
with the message under the field.
</p>
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="project in projects"
:key="project.id"
class="flex items-center justify-between gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3"
>
<span class="min-w-0 truncate text-sm">{{ project.name }}</span>
<!-- The page owns the save: `onComplete` returns its promise, and
the dialog stays open and locked until it settles. -->
<BbButton
v-bb-dialog="{ id: 'dialog-rename-project', data: project, onComplete: save }"
size="sm"
variant="outline"
>
Rename
</BbButton>
</li>
</ul>
<!-- Each opening starts without the last one's errors. -->
<BbDialog id="dialog-rename-project" title="Rename project" @hide="fieldErrors = {}">
<template #default="{ data, complete }">
<!-- A rejected save rejects `complete()` too: its field errors land
under the fields, and the dialog keeps the user's edit. -->
<BbForm
id="dialog-rename-form"
:field-errors="fieldErrors"
:model="data"
@submit="() => complete(data).catch(showErrors)"
>
<BbTextInput compact label="Name" name="name" rules="required" />
</BbForm>
</template>
<template #footer="{ close }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton form="dialog-rename-form" type="submit" variant="primary">
Save
</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbDialog, BbForm, BbTextInput, vBbDialog } from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
type Project = { id: number; name: string };
const projects = ref<Project[]>([
{ id: 1, name: 'Harbor' },
{ id: 2, name: 'Atlas' },
]);
const fieldErrors = ref<Record<string, string[]>>({});
// Your API call. It rejects like a 422: the errors by field path.
async function save(draft: Project) {
await delay(null, 600);
const name = draft.name.trim();
const taken = projects.value.some(
(p) => p.id !== draft.id && p.name.toLowerCase() === name.toLowerCase()
);
if (taken)
throw Object.assign(new Error('Validation failed'), {
errors: { name: ['Another project already has this name.'] },
});
projects.value = projects.value.map((p) =>
p.id === draft.id ? { ...draft, name } : p
);
}
const showErrors = (error: { errors?: Record<string, string[]> }) => {
fieldErrors.value = error.errors ?? {};
};
</script>
- 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@submittoapp.config.errorHandler. - From code it is
edit.show(row, { onComplete: save }), on an id handle and a component handle alike. A synchronousonComplete, or none, closes at once. onCompleteis 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 pageprovide/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.
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.
OnDemand.vue
<template>
<div class="flex flex-col items-start gap-3">
<div class="flex flex-wrap gap-2">
<BbBadge v-for="label in labels" :key="label">{{ label }}</BbBadge>
</div>
<!-- No <BbDialog> on this page: the button opens the component, and the
app's <BbHost /> renders it for this one opening. -->
<BbButton
v-bb-dialog="{ component: NewLabelDialog, onComplete: add }"
prepend:icon="lucide:plus"
variant="outline"
>
New label
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbButton, vBbDialog } from 'bitboss-ui';
import NewLabelDialog from './NewLabelDialog.vue';
const labels = ref(['Bug', 'Design']);
const add = (label: string) => labels.value.push(label);
</script>
NewLabelDialog.vue
<template>
<!-- Mounted by <BbHost /> for one opening and unmounted after it, so
every open starts blank: nothing to reset. -->
<BbDialog id="dialog-new-label" v-slot="{ data, complete }" title="New label">
<BbForm
class="grid gap-3"
:model="data"
@submit="async (values) => complete(await create(values))"
>
<BbTextInput compact label="Label name" name="name" placeholder="e.g. Priority" />
<BbButton class="justify-self-end" type="submit" variant="primary">
Create
</BbButton>
</BbForm>
</BbDialog>
</template>
<script setup lang="ts">
import { BbButton, BbDialog, BbForm, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
// Your API call: resolves the saved label.
const create = (values: Record<string, unknown>) =>
delay(String(values.name ?? '').trim() || 'Untitled', 500);
</script>
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
provideor 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: ashow()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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-dialog="'dialog-recipients-review'" variant="outline">
Review recipients
</BbButton>
<!--
The body is the only part that scrolls; `pt:content` caps it so the
list scrolls on a laptop too. `description` is the muted line under
the title and the dialog's accessible description.
-->
<BbDialog
id="dialog-recipients-review"
description="Q3 price update. Untick anyone who should not receive it."
pt:content="max-h-[min(55dvh,24rem)]"
size="md"
title="Recipients"
@show="selectAll"
>
<div class="mb-2 flex items-center justify-between gap-3">
<p class="m-0 text-xs opacity-70" role="status">
{{ selected.length }} of {{ recipients.length }} selected
</p>
<BbButton size="sm" variant="ghost" @click="toggleAll">
{{ allSelected ? 'Select none' : 'Select all' }}
</BbButton>
</div>
<!-- The dialog title names the list, so the legend is for assistive tech only. -->
<BbCheckboxGroup
id="dialog-recipients"
v-model="selected"
hide-legend
item-layout="label-fill"
item-text="fullName"
item-value="id"
:items="recipients"
legend="Recipients"
list-layout="vertical-stretch"
name="recipients"
>
<template #label="{ item }">
<span class="flex items-baseline justify-between gap-4">
<span>{{ item.fullName }}</span>
<span class="truncate text-xs opacity-70">{{ item.email }}</span>
</span>
</template>
</BbCheckboxGroup>
<!-- Pinned under the scroll area. One primary, and the slot's own `close`. -->
<template #footer="{ close }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton :disabled="!selected.length" variant="primary" @click="close">
Send to {{ selected.length }}
</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbCheckboxGroup, BbDialog, vBbDialog } from 'bitboss-ui';
import { users } from '~/demo-data';
const recipients = users;
const selected = ref<number[]>([]);
const allSelected = computed(() => selected.value.length === recipients.length);
// Everyone starts included on every open; untick the ones to leave out.
function selectAll() {
selected.value = recipients.map((person) => person.id);
}
function toggleAll() {
if (allSelected.value) selected.value = [];
else selectAll();
}
</script>
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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-dialog="'dialog-export-orders'" variant="outline">
Export orders
</BbButton>
<BbDialog id="dialog-export-orders">
<!--
A leading file badge is something `title` and `description` cannot
draw, so the whole header is yours, and so is the close control.
-->
<template #header="{ titleId, close }">
<!-- flex-1, or the block shrinks to its text and hugs the left edge. -->
<div class="flex min-w-0 flex-1 items-center gap-3">
<span
class="grid size-9 shrink-0 place-items-center rounded-(--bb-radius) bg-(--bb-muted)"
>
<BbIcon icon="lucide:file-spreadsheet" size="sm" />
</span>
<div class="min-w-0">
<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>
</div>
<BbButton
aria-label="Close"
class="self-start"
icon="lucide:x"
variant="ghost"
@click="close"
/>
</template>
<p 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="{ close }">
<BbButton variant="primary" @click="close">Export</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbDialog, BbIcon, vBbDialog } from 'bitboss-ui';
import { orders } from '~/demo-data';
</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. 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.
<template>
<div class="flex flex-wrap items-start gap-2">
<!-- One dialog for every preset: each button hands it its size as `data`. -->
<BbButton
v-for="option in presets"
:key="option.size"
v-bb-dialog="{ id: 'dialog-size-preset', data: option.size }"
variant="outline"
>
{{ option.size }} · {{ option.width }}
</BbButton>
<!-- `data` outlives the close transition, so the title holds still. -->
<BbDialog
id="dialog-size-preset"
:size="preset ?? 'sm'"
:title="`Width preset ${preset}`"
>
<p class="m-0 text-sm">
<code>size</code> caps the width. Height always hugs the content, so a short dialog
stays short.
</p>
</BbDialog>
<BbButton v-bb-dialog="'dialog-size-responsive'" variant="outline">
Responsive
</BbButton>
<!-- Only the map form re-resolves on a resize; a bare size is read once. -->
<BbDialog
id="dialog-size-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 v-bb-dialog="'dialog-size-full'" variant="outline">
Fullscreen
</BbButton>
<!-- Fullscreen ignores `size` and fills the viewport minus the page margin. -->
<BbDialog id="dialog-size-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 { BbButton, BbDialog, useBbDialogContext, vBbDialog } from 'bitboss-ui';
type Preset = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '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: 'lg', width: '512px' },
{ size: 'xl', width: '576px' },
{ size: '2xl', width: '672px' },
];
// What the clicked button handed over, typed.
const { data: preset } = useBbDialogContext<Preset>('dialog-size-preset');
</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.
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:
// 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
<template>
<div class="flex flex-col items-start gap-3">
<div class="flex flex-wrap gap-2">
<BbButton v-bb-dialog="'dialog-focus-rename'" variant="outline">
Rename workspace
</BbButton>
<BbButton v-bb-dialog="'dialog-focus-archive-dialog'" variant="outline">
Archive workspace
</BbButton>
</div>
<p class="m-0 text-sm opacity-70" role="status">Focus is on: {{ focused }}</p>
<!-- A form: start in the field, so the caret is where the typing goes. -->
<BbDialog
id="dialog-focus-rename"
focus-target="#dialog-focus-name"
title="Rename workspace"
@shown="readFocus"
>
<BbTextInput
id="dialog-focus-name"
v-model="name"
compact
label="Workspace name"
name="workspaceName"
/>
<template #footer="{ close }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton variant="primary" @click="close">Rename</BbButton>
</template>
</BbDialog>
<!-- A decision: the footer is the content, so start on the answer. -->
<BbDialog
id="dialog-focus-archive-dialog"
focus-target="#dialog-focus-archive"
title="Archive workspace?"
@shown="readFocus"
>
<p class="m-0 text-sm">
Projects become read-only and members lose edit access until the
workspace is restored.
</p>
<template #footer="{ close }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton id="dialog-focus-archive" variant="primary" @click="close">
Archive
</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbTextInput, vBbDialog } from 'bitboss-ui';
const name = ref<string | null>('Vantera');
const focused = ref('nothing yet, open one of the dialogs');
// `@shown` fires after initial focus, so this reads where it landed.
function readFocus() {
const el = document.activeElement as HTMLElement | null;
if (!el) return;
focused.value =
el.tagName === 'INPUT'
? 'the Workspace name field'
: `the ${el.getAttribute('aria-label') ?? el.textContent?.trim()} button`;
}
</script>
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-targetpicks 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 ownkey. 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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton
v-bb-dialog="{
id: 'dialog-terms',
onComplete: (answer: string) => (status = `${answer}.`),
}"
variant="outline"
>
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
id="dialog-terms"
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>
<!-- No ✕, no backdrop: the answer is the way out, and `complete`
closes with it. -->
<template #footer="{ complete }">
<BbButton variant="ghost" @click="complete('Declined')">
Decline
</BbButton>
<BbButton variant="primary" @click="complete('Accepted')">
Accept
</BbButton>
</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, vBbDialog } from 'bitboss-ui';
const status = ref('Backdrop and Escape play a nudge instead of closing.');
</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.
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
<template>
<div class="flex flex-col items-start gap-3">
<p class="m-0 text-sm">
Project · <strong>{{ project.name }}</strong>
</p>
<BbButton
v-bb-dialog="{
id: 'dialog-busy-rename',
data: project,
onComplete: (saved: Project) => (project = saved),
}"
variant="outline"
>
Rename project
</BbButton>
<!-- No `prevent-close`, no `saving` ref: while the form saves, the field
is read-only and the dialog refuses Cancel, the ×, Escape and the
backdrop by itself. -->
<BbDialog id="dialog-busy-rename" title="Rename project">
<template #default="{ data, complete }">
<BbForm
id="dialog-busy-form"
:model="data"
@submit="async () => complete(await save(data))"
>
<BbTextInput compact label="Project name" name="name" />
</BbForm>
</template>
<template #footer="{ data, close }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<!-- Outside the form: `form` submits it, and it spins with the save. -->
<BbButton
:disabled="!data.name?.trim()"
form="dialog-busy-form"
type="submit"
variant="primary"
>
Rename
</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbForm, BbTextInput, vBbDialog } from 'bitboss-ui';
import { delay } from '~/demo-data';
type Project = { name: string };
const project = ref<Project>({ name: 'vantera-storefront' });
// Your API call. Return its promise: the form, and the dialog around it, stay
// locked until it settles.
const save = (draft: Project) => delay({ name: draft.name.trim() }, 1200);
</script>
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:
- <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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-dialog="'dialog-stack-storage'" variant="outline">
Storage settings
</BbButton>
<!-- Same `stack` name: opening the second hides the first, it does not layer. -->
<BbDialog id="dialog-stack-storage" stack="storage" title="Storage">
<div class="grid gap-3">
<BbSwitch
id="dialog-stack-backups"
v-model="autoBackup"
label="Nightly backups"
name="autoBackup"
/>
<BbButton
v-bb-dialog="'dialog-stack-retention'"
append:icon="lucide:chevron-right"
variant="outline"
>
Advanced retention
</BbButton>
</div>
</BbDialog>
<BbDialog id="dialog-stack-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="{ close }">
<!-- Closing this one brings the first back. -->
<BbButton variant="primary" @click="close">Back</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDialog, BbSwitch, vBbDialog } from 'bitboss-ui';
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. 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
<template>
<div class="flex w-full flex-col gap-3">
<p class="m-0 text-sm font-medium">Recent orders</p>
<div
class="flex items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3"
>
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm font-medium">Order 4812</p>
<p class="m-0 truncate text-xs opacity-70">Northwind Labs · €1,240.00</p>
</div>
<BbButton v-bb-dialog="'dialog-adaptive-order'" size="sm" variant="outline">
View
</BbButton>
</div>
<!--
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: the footer's Close is enough, so the sheet drops
its ×.
-->
<BbDialog
id="dialog-adaptive-order"
:off-canvas-props="{ hideClose: 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>
<!-- `close` works the same on the modal and on the sheet. -->
<template #footer="{ close }">
<BbButton variant="primary" @click="close">Close</BbButton>
</template>
</BbDialog>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbDialog, vBbDialog } from 'bitboss-ui';
</script>
: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:
<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.
.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).
.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.