Use it for
Reach for BbOffCanvas for content that lives beside the main task rather than
replacing it: a filter panel, a record's detail view, a settings drawer. The
user is topping up context, not switching to a different job.
Use something else when
BbDialog: it is one task the user must finish before anything elseBbPopover: the panel is anchored to the control that opened itBbDropdown: the anchored content is a menu of actionsBbConfirm: it is a yes/no- A page: the drawer has grown tabs, a list and sub-navigation
Underneath, BbOffCanvas and BbDialog are the
same machine: the same opening by id, the same slots, the same events, the
same dismissal props. Moving a surface between them is mostly a tag swap. This page
covers what genuinely differs: the edge, the extent, the drag, and a stack that
behaves the other way round.
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
Give the panel an id and put v-bb-off-canvas on the button that opens it,
with the id in inner quotes. You need no ref(false), no @click and no
v-model:
<BbButton v-bb-off-canvas="'filters'" variant="outline">Filters</BbButton>
<BbOffCanvas id="filters" side="right" title="Filters">
…
<template #footer="{ close }">
<BbButton variant="primary" @click="close">Apply</BbButton>
</template>
</BbOffCanvas>
It works exactly like v-bb-dialog:
v-bb-off-canvas="{ id: 'member', data: member }"hands the panel what it was opened for, as a fresh copy per opening. The slots receive it asdata, anduseBbOffCanvasContext<T>(id)returns it typed, withopen,isReady,show(data?),hide(),complete(value)andreset(). Ashow()on the open panel joins it and changes nothing.- Many rows open one panel, each with its own
data, as the stack below does. Never render one panel per row: theidis also the DOM id. complete(value)closes with a result for the trigger'sonComplete(orawait show(), as on the dialog), andhide()closes with none; both work even underprevent-close. The slots'close()is the user's close.- The page can own the save. An
onCompletethat returns the request's promise keeps the panel open and locked until it settles, and a rejection keeps the user's edit, as on the dialog. @hidecarries the opening's result, a swipe included. To tell a save from a cancel, close a save withcomplete(), not by writing the model: on av-modelpanel too it reports{ completed: true }.- A
v-modelon the panel still wins. Keep it for a panel your state opens.
Filters beside the page
Hand the panel the applied filters as data: it edits its own copy, so
closing it any other way discards the change, and the footer's Show hands the
copy back with complete(data). Its
default-data is "no
filters", and the footer's Reset is the slot's reset(): it restores every key
that default declares, from a fresh copy.
Orders
20 orders · no filters
<template>
<div class="flex w-full max-w-md items-center justify-between gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3">
<div class="min-w-0">
<p class="m-0 text-sm font-medium">Orders</p>
<p class="m-0 text-xs opacity-70">{{ summary }}</p>
</div>
<!-- The panel edits its own copy of the applied filters; Show hands the
copy back, and closing any other way discards it. -->
<BbButton
v-bb-off-canvas="{
id: 'offcanvas-filter-orders',
data: applied,
onComplete: (next: Filters) => (applied = next),
}"
prepend:icon="lucide:sliders-horizontal"
variant="outline"
>
Filters{{ activeCount ? ` · ${activeCount}` : '' }}
</BbButton>
<!-- `default-data` is "no filters": Reset restores a fresh copy of it. -->
<BbOffCanvas
id="offcanvas-filter-orders"
:default-data="NO_FILTERS"
side="right"
title="Filter orders"
>
<template #default="{ data }">
<div class="grid gap-5">
<BbTextInput
id="offcanvas-filter-query"
v-model="data.query"
compact
label="Search"
name="query"
placeholder="Order reference"
prepend:icon="lucide:search"
/>
<BbCheckboxGroup
id="offcanvas-filter-status"
v-model="data.statuses"
:items="statusItems"
legend="Status"
list-layout="tiles"
name="statuses"
pt:list="[--tile-min-width:140px]"
/>
<BbRadioGroup
id="offcanvas-filter-channel"
v-model="data.channel"
:items="channelItems"
legend="Channel"
list-layout="vertical"
name="channel"
/>
</div>
</template>
<!-- The footer stays pinned while the body scrolls. One primary action. -->
<template #footer="{ data, complete, reset }">
<BbButton class="me-auto" variant="ghost" @click="reset">Reset</BbButton>
<!-- Counts the copy, so the reader sees the result before applying it. -->
<BbButton variant="primary" @click="complete(data)">
Show {{ count(data) }} orders
</BbButton>
</template>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BbButton,
BbCheckboxGroup,
BbOffCanvas,
BbRadioGroup,
BbTextInput,
vBbOffCanvas,
} from 'bitboss-ui';
import { orders, orderStatuses, orderStatusLabels } from '~/demo-data';
import type { OrderChannel, OrderStatus } from '~/demo-data';
type Filters = {
// BbTextInput emits null when the field is emptied.
query: string | null;
statuses: OrderStatus[];
channel: OrderChannel | 'any';
};
const statusItems = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const channelItems = [
{ value: 'any', label: 'Any channel' },
{ value: 'web', label: 'Web' },
{ value: 'phone', label: 'Phone' },
{ value: 'marketplace', label: 'Marketplace' },
];
const NO_FILTERS: Filters = { query: '', statuses: [], channel: 'any' };
const applied = ref<Filters>({ ...NO_FILTERS });
const count = (filters: Filters) =>
orders.filter((order) => {
if (filters.statuses.length && !filters.statuses.includes(order.status))
return false;
if (filters.channel !== 'any' && order.channel !== filters.channel)
return false;
const query = (filters.query ?? '').trim().toLowerCase();
return !query || order.reference.toLowerCase().includes(query);
}).length;
const activeCount = computed(
() =>
Number(Boolean((applied.value.query ?? '').trim())) +
Number(applied.value.statuses.length > 0) +
Number(applied.value.channel !== 'any')
);
const summary = computed(() =>
activeCount.value
? `${count(applied.value)} of ${orders.length} match`
: `${orders.length} orders · no filters`
);
</script>
Pass title. It draws the header and gives the panel its accessible name, and a
drawer that announces only "dialog" tells nobody what just appeared beside their
table.
The panel dismisses itself on the ×, on Escape and on a backdrop click. Focus,
Escape and layering behave exactly as they do on the dialog, and they are
described once on
BbDialog → Focus and the top layer.
Edge, structure and extent
side picks the edge the panel docks to. left is the default.
<template>
<div class="flex flex-wrap items-start gap-2">
<!-- One panel for every edge: each button hands it its side as `data`. -->
<BbButton
v-for="edge in edges"
:key="edge"
v-bb-off-canvas="{ id: 'offcanvas-docked', data: edge }"
variant="outline"
>
{{ edge }}
</BbButton>
<!-- `data` outlives the close transition, so the panel leaves by its own edge. -->
<BbOffCanvas
id="offcanvas-docked"
:side="active ?? 'left'"
:title="`Docked ${active}`"
>
<p class="m-0 text-sm">
A <code>left</code> or <code>right</code> panel takes its width from
<code>size</code> and fills the height; a <code>top</code> or
<code>bottom</code> one does the opposite.
</p>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbOffCanvas, useBbOffCanvasContext, vBbOffCanvas } from 'bitboss-ui';
type Side = 'left' | 'right' | 'top' | 'bottom';
const edges: Side[] = ['left', 'right', 'top', 'bottom'];
const { data: active } = useBbOffCanvasContext<Side>('offcanvas-docked');
</script>
The choice is not only cosmetic. The side also decides:
- which dimension
sizecontrols - which axis the drag runs along
- which edge a stacked parent peeks past
Use right for detail and filter panels, bottom for phone-style sheets, top
for banners and command surfaces. Every panel keeps its bands clear of the notch
and the home indicator on the edges it touches, as long as the page's viewport
meta has viewport-fit=cover.
Coming from v2direction → side
This prop was called direction in v2, and the compiler cannot see the rename.
A leftover direction falls into $attrs and the panel docks to left; a
development build warns, and npx bitboss-ui check --fix renames it:
- <BbOffCanvas v-model="open" direction="right" />
+ <BbOffCanvas v-model="open" side="right" />
The same rename applies inside off-canvas-props on every component that opens
a drawer on mobile. eslint --fix rewrites all of them.
Header, body and footer
The body is the only one of the three regions that scrolls: header and footer stay pinned.
Actions belong in footer, one primary (Apply, Save, Assign) with its
counterweight beside it. The filters demo also shows the pattern the
drawer inherits from the dialog: the panel edits its own copy of data, fresh on
every opening. The reasoning is on
BbDialog → Editing a record.
Use the title slot when only the title text needs restyling. The header slot
replaces the whole header instead, and providing it renders a header even with
no title.
description adds one
muted line under the title, read as the panel's description; the
description slot does the
same when the line needs markup.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-off-canvas="'offcanvas-notifications'" variant="outline">
Notifications
</BbButton>
<BbOffCanvas id="offcanvas-notifications" side="right">
<!--
Providing #header is enough to render a header; no `title` needed.
It replaces the ×, so the close control is yours, and `titleId` is
what keeps aria-labelledby pointing at something real.
-->
<template #header="{ titleId, close }">
<h2 :id="titleId" class="m-0 flex-1 text-base font-medium">
Notifications
<span class="opacity-70">({{ unread.length }})</span>
</h2>
<div class="flex items-center gap-1 self-start">
<BbButton size="sm" variant="ghost" @click="markAllRead">
Mark all read
</BbButton>
<BbButton
aria-label="Close notifications"
icon="lucide:x"
variant="ghost"
@click="close"
/>
</div>
</template>
<ul class="m-0 grid list-none gap-3 p-0 text-sm">
<li v-for="item in items" :key="item.id" class="grid gap-0.5">
<span :class="{ 'opacity-60': item.read }">{{ item.text }}</span>
<span class="text-xs opacity-70">{{ item.when }}</span>
</li>
</ul>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbOffCanvas, vBbOffCanvas } from 'bitboss-ui';
type Notification = {
id: number;
text: string;
when: string;
read: boolean;
};
const items = ref<Notification[]>([
{
id: 1,
text: 'ORD-2026-0417 was refunded.',
when: 'Today, 09:14',
read: false,
},
{
id: 2,
text: 'Marta Villoresi commented on the Q3 report.',
when: 'Yesterday, 17:40',
read: false,
},
{ id: 3, text: 'Two invoices are overdue.', when: '29 May', read: true },
]);
const unread = computed(() => items.value.filter((item) => !item.read));
function markAllRead() {
for (const item of items.value) item.read = true;
}
</script>
It receives titleId, title and close. Put titleId on your heading, or the
panel has no accessible name. Render something wired to close, because the
slot replaces the default ×. With no title and no header slot there is no
header at all, and the × floats over the top corner of the body, as on the
dialog.
Coming from v2
#close is gone; a custom close control lives inside #header.
Extent, auto and fullscreen
size is the panel's extent along its own axis, not a width.
<template>
<div class="flex flex-wrap items-start gap-2">
<BbButton v-bb-off-canvas="'offcanvas-size-wide'" variant="outline">
right · lg
</BbButton>
<!-- On a left/right panel, `size` is the width. -->
<BbOffCanvas id="offcanvas-size-wide" side="right" size="lg" title="Order detail">
<p class="m-0 text-sm">
512px wide, full height. The same <code>lg</code> on a bottom panel
would be 512px <em>tall</em>.
</p>
</BbOffCanvas>
<BbButton v-bb-off-canvas="'offcanvas-size-tall'" variant="outline">
bottom · lg
</BbButton>
<BbOffCanvas id="offcanvas-size-tall" side="bottom" size="lg" title="Order detail">
<p class="m-0 text-sm">
Same preset, other axis: 512px tall, full width.
</p>
</BbOffCanvas>
<BbButton v-bb-off-canvas="'offcanvas-size-auto'" variant="outline">
bottom · auto
</BbButton>
<!-- `auto` is fit-content: the panel is exactly as tall as its content. -->
<BbOffCanvas id="offcanvas-size-auto" side="bottom" size="auto" title="Share">
<div class="flex flex-wrap gap-2">
<BbButton prepend:icon="lucide:link" variant="outline">Copy link</BbButton>
<BbButton prepend:icon="lucide:mail" variant="outline">Email</BbButton>
</div>
</BbOffCanvas>
<BbButton v-bb-off-canvas="'offcanvas-size-full'" variant="outline">
fullscreen
</BbButton>
<!-- Fullscreen ignores `size` and fills the viewport. -->
<BbOffCanvas id="offcanvas-size-full" fullscreen side="right" title="Order detail">
<p class="m-0 text-sm">
<code>fullscreen="mobile"</code> does this only below the mobile
breakpoint and keeps the sized panel on desktop.
</p>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbOffCanvas, vBbOffCanvas } from 'bitboss-ui';
</script>
On a left or right panel that is the width; on top or bottom it is the
height. The same size="lg" is 512px across on one and 512px tall on the other,
which is the most common misreading of this prop.
The presets run from xs (320px) to 2xl (672px), and sm (384px) is the
default. auto sizes the panel to its content, which is what makes a
content-sized sheet possible. A per-breakpoint map is the only form that reacts
to a live resize.
Coming from v2preset widths
The preset values changed between v2 and v3 here too, as silently. v2
shipped { sm: 384, md: 652, lg: 896 }, so a size="md" panel is now roughly
200px narrower. Pin the old values if a layout depended on them:
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { offCanvasDefaultSizes: { md: 652, lg: 896 } },
});
fullscreen covers the whole viewport, edge to edge, unlike the dialog's
margin; fullscreen="mobile" does that only below the mobile breakpoint. That
is the usual answer for a detail panel that should take the whole screen on a
phone.
Draggable sheets
draggable lets someone drag the panel toward its edge to dismiss it, and puts
a grab handle on the inner edge.
Nothing added yet.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton
v-bb-off-canvas="{
id: 'offcanvas-pick-product',
onComplete: (name: string) => (status = `Added ${name}.`),
}"
variant="outline"
>
Pick a product
</BbButton>
<!--
draggable + side="bottom" + size="auto" is the canonical mobile sheet.
The body scrolls first: you only start dragging the sheet once the list
is at its top edge.
-->
<BbOffCanvas
id="offcanvas-pick-product"
draggable
side="bottom"
size="auto"
title="Add to order"
>
<template #default="{ complete }">
<!-- Rows stay scannable on desktop, where the sheet spans the viewport. -->
<ul class="m-0 mx-auto grid w-full max-w-lg list-none gap-1 p-0">
<li
v-for="product in catalogue"
:key="product.id"
class="flex items-center gap-3 py-1"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm">{{ product.name }}</span>
<span class="block text-xs opacity-70">
{{ price.format(product.price) }}
</span>
</span>
<BbButton
:aria-label="`Add ${product.name}`"
prepend:icon="lucide:plus"
size="sm"
variant="outline"
@click="complete(product.name)"
>
Add
</BbButton>
</li>
</ul>
</template>
</BbOffCanvas>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbOffCanvas, vBbOffCanvas } from 'bitboss-ui';
import { products } from '~/demo-data';
const status = ref('Nothing added yet.');
const catalogue = products.slice(0, 8);
const price = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: products[0]!.currency,
});
</script>
A draggable, auto-sized bottom panel is the canonical mobile sheet, and it
is exactly what an adaptive
BbDialog becomes on a phone.
Three rules govern the gesture. Dragging about 50px toward the edge dismisses
it; a shorter drag springs back. On
a sheet with a scrollable body the drag hands off to native scrolling first, so
scrolling a long list never closes it by accident. And persistent or
prevent-close win: the drag plays the deny nudge and springs back.
Content with its own drag, such as a slider, opts out with a data-bb-no-drag
attribute. A gesture that starts inside it never moves the panel.
Dragging is a convenience, never the only way out. The close button, the
backdrop and Escape all remain.
Snap points
A draggable bottom sheet can rest at more than one height. List the stops in
snap-points, as fractions
of the screen or 'Npx', and bind the current one with v-model:snap.
Resting at 220px
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-off-canvas="'offcanvas-nearby'" variant="outline">
Nearby stores
</BbButton>
<p class="m-0 text-xs opacity-70">Resting at {{ snap }}</p>
<!-- Three stops: it opens at the first, a drag or the grabber's arrow
keys move between them, and only a drag below the first closes. -->
<BbOffCanvas
id="offcanvas-nearby"
v-model:snap="snap"
draggable
side="bottom"
:snap-points="['220px', 0.6, 1]"
title="Nearby stores"
@hidden="snap = '220px'"
>
<ul class="m-0 grid list-none gap-2 p-0">
<li
v-for="store in stores"
:key="store.id"
class="flex items-center justify-between gap-3 rounded-(--bb-radius) border border-(--bb-border) p-3 text-sm"
>
{{ store.name }}
<span class="text-xs opacity-70">{{ store.distance }}</span>
</li>
</ul>
<template #footer>
<BbButton class="w-full" variant="primary" @click="snap = 1">
Show all {{ stores.length }} stores
</BbButton>
</template>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbOffCanvas, type BbSnapPoint, vBbOffCanvas } from 'bitboss-ui';
// The stop the sheet rests at, as written in `snap-points`.
const snap = ref<BbSnapPoint>('220px');
const stores = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
name: `Store ${i + 1}`,
distance: `${(0.3 + i * 0.4).toFixed(1)} km`,
}));
</script>
- It opens at the first stop, and a release moves at most one stop, the way the flick went. Only a drag below the first stop closes it.
- The body scrolls only at the top stop; lower down, a drag moves the sheet. The footer stays on screen at every stop.
- The grabber is a slider: arrow keys, Home and End move between stops, and
a tap moves to the next one. Setting the model (
snap = 1above) moves it too. - An adaptive
BbDialogpassessnap-pointsandv-model:snapto its phone sheet.
Stacks
Panels sharing a stack name step back behind one another as you drill in.
Here each member row opens the one detail panel with its person as data.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton v-bb-off-canvas="'offcanvas-team'" variant="outline">
Team members
</BbButton>
<!-- Same `stack` name and same `side`: the parent steps back and peeks. -->
<BbOffCanvas
id="offcanvas-team"
side="right"
size="xs"
stack="team"
title="Team members"
>
<ul class="m-0 grid list-none gap-1 p-0">
<li
v-for="person in members"
:key="person.id"
class="flex items-center gap-3 py-1"
>
<BbAvatar :alt="person.fullName" size="32" :src="person.avatar" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm">{{ person.fullName }}</span>
<span class="block truncate text-xs opacity-70">
{{ person.jobTitle }}
</span>
</span>
<!-- Opens the detail panel below with this person as its `data`. -->
<BbButton
v-bb-off-canvas="{ id: 'offcanvas-team-member', data: person }"
:aria-label="`View ${person.fullName}`"
icon="lucide:chevron-right"
size="sm"
variant="ghost"
/>
</li>
</ul>
</BbOffCanvas>
<BbOffCanvas
id="offcanvas-team-member"
side="right"
size="xs"
stack="team"
>
<!-- The person the row handed over, in every slot. -->
<template #title="{ data }">{{ data.fullName }}</template>
<template #default="{ data }">
<dl class="m-0 grid gap-2 text-sm">
<div class="grid gap-0.5">
<dt class="text-xs opacity-70">Role</dt>
<dd class="m-0">{{ data.role }}</dd>
</div>
<div class="grid gap-0.5">
<dt class="text-xs opacity-70">Team</dt>
<dd class="m-0">{{ data.team }}</dd>
</div>
<div class="grid gap-0.5">
<dt class="text-xs opacity-70">Joined</dt>
<dd class="m-0">{{ joined(data.joinedAt) }}</dd>
</div>
</dl>
</template>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import {
BbAvatar,
BbButton,
BbOffCanvas,
vBbOffCanvas,
} from 'bitboss-ui';
import { users } from '~/demo-data';
const members = users.slice(0, 6);
// Fixed locale and UTC, so the server and the browser print the same month.
const joined = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
</script>
Opening a child shifts its parent inward by stack-gap (60px by default),
leaving a band of it visible. Closing the child brings it forward again. That
band only appears for panels sharing a name and a side, so a drawer that
does not peek usually differs in one of the two.
Every open panel dims the one beneath it; only the bottom one blurs. Set
--bb-overlay-above: transparent to keep a single dim.
This is the opposite of the dialog's stack, which replaces. Pick the drawer's
behaviour when the trail should stay visible, and
BbDialog's when the flow should read as one
surface whose content swaps.
Controlling dismissal
persistent stops the backdrop, Escape and a drag from closing the panel.
The × still closes it.
Escape and the backdrop play a nudge instead of closing.
<template>
<div class="flex flex-col items-start gap-3">
<!-- Each opening starts from its own copy of `data`: a blank field. -->
<BbButton
v-bb-off-canvas="{
id: 'offcanvas-assign-reviewer',
data: { reviewer: '' },
onComplete: (name: string) => (status = `Assigned to ${name}.`),
}"
variant="outline"
>
Assign reviewer
</BbButton>
<!--
persistent blocks the backdrop, Escape and the drag; the × would still
close, so hide-close goes with it, and the footer owes the user a way
out. focus-target puts the caret in the field on open.
-->
<BbOffCanvas
id="offcanvas-assign-reviewer"
focus-target="#offcanvas-persistent-reviewer"
hide-close
persistent
side="right"
title="Assign a reviewer"
>
<template #default="{ data }">
<BbTextInput
id="offcanvas-persistent-reviewer"
v-model="data.reviewer"
compact
label="Reviewer"
name="reviewer"
placeholder="Name or email"
/>
</template>
<!-- `close` acts like the ×, which `persistent` still allows; only
`prevent-close` would refuse it. -->
<template #footer="{ data, close, complete }">
<BbButton variant="ghost" @click="close">Cancel</BbButton>
<BbButton
:disabled="!data.reviewer?.trim()"
variant="primary"
@click="complete(data.reviewer.trim())"
>
Assign
</BbButton>
</template>
</BbOffCanvas>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbOffCanvas, BbTextInput, vBbOffCanvas } from 'bitboss-ui';
const status = ref('Escape and the backdrop play a nudge instead of closing.');
</script>
Pair it with hide-close when the choice really has to be made from the footer,
and keep a visible Cancel there, wired to the footer scope's close, which still
works while the panel is persistent.
A BbForm inside the panel guards a save by itself: while its @submit
promise runs, the fields are read-only and the panel refuses the ×, Escape,
the backdrop, a drag and the footer scope's close, as the
dialog does. Close with complete or
hide() once the save resolves.
prevent-close is for
the rest: true refuses every user close for work that is not a form submit,
false keeps the panel closable even mid-save. Beside a BbForm, bind another
action's flag as :prevent-close="deleting || undefined": a plain false
while idle would switch the form's lock off.
focus-target is a CSS selector and chooses only where focus starts; without it
the first tabbable element receives it.
Coming from v2show-close → hide-close
The close-button prop inverted in v3 here as well. v2's show-close (default
true) is v3's hide-close (default false):
- <BbOffCanvas v-model="open" direction="right" :show-close="false" />
+ <BbOffCanvas v-model="open" side="right" hide-close />
A leftover :show-close="false" falls into $attrs and the × comes back on the
panel built without one. eslint --fix rewrites it, along with the direction
on the same line.
Padding and parts
Spacing reaches the panel through --bb-panel-p (16px), split into the local
pair --px and --py on .bb-offcanvas. Set them on the component's class,
never on :root.
.my-drawer {
--px: 24px;
--py: 10px;
}
/* Per-part: scope --py to the part, and the derived spacings follow. */
.my-drawer .bb-offcanvas__header {
--py: 10px;
}
The parts are .bb-offcanvas__header, __title, __description, __close,
__content, __body-content, __footer and __handle. The root carries its
state as modifier classes: --open, --visible, --fullscreen, --dragging,
--covered, --has-handle, --no-header, and one per side. Those are the
hooks to target, and ordinary CSS reaches them because the panel is a real
element in your DOM.
Each part is also a pt key: root, header, title,
description, close, content and footer.
A closed panel is display: none once its close transition ends, so a closed
drawer measures zero and adds nothing to the page width. Measure the panel in
@shown, not before it opens.
Three knobs still carry the dialog's name, and they are not typos:
--dialog-gap is the body-to-footer rhythm, --dialog-title-fs the title's
size and --dialog-title-fw its weight.
Coming from v2compact and close token
.bb-offcanvas--compact went with the compact prop. A leftover compact
lands in $attrs and changes nothing; a development build warns that it was
removed. Delete
--bb-dialog-close rather than porting it. The full mapping is on
BbDialog → Padding and the title and applies
here unchanged.