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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Underneath, BbOffCanvas and BbDialog are the
same machine: the same v-model, 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.
Filters beside the page
Keep filter state in a draft, then apply it from the footer. Bind the panel to
v-model; there is no open() to call.
20 of 20 orders match.
<template>
<div class="flex flex-col items-start gap-3">
<p class="m-0 text-sm opacity-70">{{ summary }}</p>
<BbButton
prepend:icon="lucide:sliders-horizontal"
variant="outline"
@click="open = true"
>
Filters
</BbButton>
<!-- @show reseeds the working copy, so Cancel discards rather than commits. -->
<BbOffCanvas v-model="open" side="right" title="Filter orders" @show="seed">
<div class="grid gap-3">
<BbTextInput
id="offcanvas-filter-query"
v-model="draft.query"
compact
label="Search"
name="query"
placeholder="Reference or customer"
/>
<BbCheckbox
id="offcanvas-filter-unpaid"
v-model="draft.unpaidOnly"
label="Unpaid only"
name="unpaidOnly"
/>
<BbCheckbox
id="offcanvas-filter-web"
v-model="draft.webOnly"
label="Web channel only"
name="webOnly"
/>
</div>
<!-- The footer stays pinned while the body scrolls. One primary action. -->
<template #footer>
<div class="flex justify-end gap-2">
<BbButton variant="ghost" @click="clear">Reset</BbButton>
<BbButton variant="primary" @click="apply">Apply</BbButton>
</div>
</template>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { BbButton, BbCheckbox, BbOffCanvas, BbTextInput } from 'bitboss-ui';
import { orders } from '~/demo-data';
type Filters = {
query: string;
unpaidOnly: boolean;
webOnly: boolean;
};
const empty: Filters = { query: '', unpaidOnly: false, webOnly: false };
const open = ref(false);
const applied = reactive<Filters>({ ...empty });
const draft = reactive<Filters>({ ...empty });
function seed() {
Object.assign(draft, applied);
}
function clear() {
Object.assign(draft, empty);
}
function apply() {
Object.assign(applied, draft);
open.value = false;
}
const matching = computed(() =>
orders.filter((order) => {
if (applied.unpaidOnly && order.status === 'delivered') return false;
if (applied.webOnly && order.channel !== 'web') return false;
if (!applied.query) return true;
return order.reference
.toLowerCase()
.includes(applied.query.trim().toLowerCase());
})
);
const summary = computed(
() => `${matching.value.length} of ${orders.length} orders match.`
);
</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">
<BbButton
v-for="edge in edges"
:key="edge"
variant="outline"
@click="active = edge"
>
{{ edge }}
</BbButton>
<BbOffCanvas
:model-value="active !== null"
:side="active ?? 'left'"
:title="`Docked ${active}`"
@update:model-value="onToggle"
>
<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 { ref } from 'vue';
import { BbButton, BbOffCanvas } from 'bitboss-ui';
type Side = 'left' | 'right' | 'top' | 'bottom';
const edges: Side[] = ['left', 'right', 'top', 'bottom'];
const active = ref<Side | null>(null);
function onToggle(value: boolean) {
if (!value) active.value = null;
}
</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
Every later section on this page refers back to it.
Use right for detail and filter panels, bottom for phone-style sheets, top
for banners and command surfaces. A bottom panel handles the phone's home
indicator itself, so its last row never lands under it.
This prop was called direction in v2, and the rename is silent. A leftover
direction falls into $attrs and the panel docks to left:
- <BbOffCanvas v-model="open" direction="right" title="Filters" />
+ <BbOffCanvas v-model="open" side="right" title="Filters" />
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. That demo also shows the pattern the drawer inherits
from the dialog: the panel edits a draft, which @show reseeds on every open.
The reasoning is on
BbDialog → Forms and drafts.
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.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">Notifications</BbButton>
<BbOffCanvas v-model="open" 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">
<BbButton size="sm" variant="ghost" @click="markAllRead">
Mark all read
</BbButton>
<BbButton icon="lucide:x" size="sm" variant="ghost" @click="close">
Close notifications
</BbButton>
</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 } from 'bitboss-ui';
type Notification = {
id: number;
text: string;
when: string;
read: boolean;
};
const open = ref(false);
const items = ref<Notification[]>([
{
id: 1,
text: 'ORD-2026-0417 was refunded.',
when: '2026-06-02',
read: false,
},
{
id: 2,
text: 'Marta Villoresi commented on the Q3 report.',
when: '2026-06-01',
read: false,
},
{ id: 3, text: 'Two invoices are overdue.', when: '2026-05-29', 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, an off-canvas has no close button
anywhere. That is the difference from the dialog people walk into. The panel
is left with Escape, the backdrop and whatever you put in the body. That is
fine for a drawer whose body ends in a Close button. Combined with persistent,
it is a trap.
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 a screen reader needs it. #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 variant="outline" @click="wide = true">right · lg</BbButton>
<!-- On a left/right panel, `size` is the width. -->
<BbOffCanvas v-model="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 variant="outline" @click="tall = true">bottom · lg</BbButton>
<BbOffCanvas v-model="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 variant="outline" @click="hugging = true">bottom · auto</BbButton>
<!-- `auto` is fit-content: the panel is exactly as tall as its content. -->
<BbOffCanvas v-model="hugging" side="bottom" size="auto" title="Share">
<div class="flex flex-wrap gap-2">
<BbButton append:icon="lucide:link" variant="outline">Copy</BbButton>
<BbButton append:icon="lucide:mail" variant="outline">Email</BbButton>
</div>
</BbOffCanvas>
<BbButton variant="outline" @click="full = true">fullscreen</BbButton>
<!-- Fullscreen ignores `size` and fills the viewport. -->
<BbOffCanvas v-model="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 { ref } from 'vue';
import { BbButton, BbOffCanvas } from 'bitboss-ui';
const wide = ref(false);
const tall = ref(false);
const hugging = ref(false);
const full = ref(false);
</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.
The preset values changed between v2 and v3 here too, just 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 fills the viewport minus the standard page margin, and
fullscreen="mobile" does that only on small viewports. That is the usual
answer for a detail panel that should take the whole screen on a phone.
Draggable sheets
draggable lets someone flick 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 variant="outline" @click="open = true">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
v-model="open"
draggable
side="bottom"
size="auto"
title="Add to order"
>
<template #default="{ close }">
<ul class="m-0 grid list-none gap-1 p-0">
<li v-for="product in catalogue" :key="product.id">
<BbButton
block
class="justify-start"
size="sm"
variant="ghost"
@click="choose(product.name, close)"
>
{{ product.name }}
</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 } from 'bitboss-ui';
import { products } from '~/demo-data';
const open = ref(false);
const status = ref('Nothing added yet.');
const catalogue = products.slice(0, 8);
function choose(name: string, close: () => void) {
status.value = `Added ${name}.`;
close();
}
</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 past a quarter of the panel's extent,
or releasing with a fast flick, dismisses it; anything shorter 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
disabled win: the drag plays the deny nudge and springs back.
Dragging is a convenience, never the only way out. The close button, the
backdrop and Escape all remain.
Stacks
Panels sharing a stack name step back behind one another as you drill in.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="listOpen = true">Team members</BbButton>
<!-- Same `stack` name and same `side`: the parent steps back and peeks. -->
<BbOffCanvas
v-model="listOpen"
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">
<BbButton
block
class="justify-start"
size="sm"
variant="ghost"
@click="inspect(person.id)"
>
{{ person.fullName }}
</BbButton>
</li>
</ul>
</BbOffCanvas>
<BbOffCanvas
v-model="detailOpen"
side="right"
size="xs"
stack="team"
:title="selected?.fullName"
>
<dl v-if="selected" 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">{{ selected.role }}</dd>
</div>
<div class="grid gap-0.5">
<dt class="text-xs opacity-70">Team</dt>
<dd class="m-0">{{ selected.team }}</dd>
</div>
<div class="grid gap-0.5">
<dt class="text-xs opacity-70">Joined</dt>
<dd class="m-0">{{ selected.joinedAt }}</dd>
</div>
</dl>
</BbOffCanvas>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbOffCanvas } from 'bitboss-ui';
import { users } from '~/demo-data';
const members = users.slice(0, 6);
const listOpen = ref(false);
const detailOpen = ref(false);
const selectedId = ref<number | null>(null);
const selected = computed(() =>
members.find((person) => person.id === selectedId.value)
);
function inspect(id: number) {
selectedId.value = id;
detailOpen.value = true;
}
</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.
Only the top two open panels keep their dimmed backdrop, which is why a three-deep stack does not read as three layers of dimming.
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.
Dragging the panel away plays a nudge instead.
<template>
<div class="flex flex-col items-start gap-3">
<BbButton variant="outline" @click="open = true">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
v-model="open"
focus-target="#offcanvas-persistent-reviewer"
hide-close
persistent
side="right"
title="Assign a reviewer"
>
<BbTextInput
id="offcanvas-persistent-reviewer"
v-model="reviewer"
compact
label="Reviewer"
name="reviewer"
placeholder="Name or email"
/>
<template #footer>
<div class="flex justify-end gap-2">
<BbButton variant="ghost" @click="close('Left unassigned')">
Cancel
</BbButton>
<BbButton
:disabled="!reviewer.trim()"
variant="primary"
@click="close(`Assigned to ${reviewer.trim()}`)"
>
Assign
</BbButton>
</div>
</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 } from 'bitboss-ui';
const open = ref(false);
const reviewer = ref('');
const status = ref('Dragging the panel away plays a nudge instead.');
function close(message: string) {
status.value = `${message}.`;
open.value = false;
}
</script>
Pair it with hide-close when the choice really has to be made from the footer,
and keep a visible Cancel there. That pairing matters more here than on the
dialog, because a headerless panel already has no × of its own.
disabled is the same guard for a busy surface, and it blocks the × as well. It
guards dismissal only: the fields and buttons inside stay live, so disable them
yourself while a save is in flight.
focus-target is a CSS selector and chooses only where focus starts; without it
the first tabbable element receives it.
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" title="Filters" />
+ <BbOffCanvas v-model="open" side="right" hide-close title="Filters" />
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, __body, __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.
Two knobs still carry the dialog's name, and they are not typos: --dialog-gap
is the body-to-footer rhythm and --dialog-title-fs the title's size.
Two v2 hooks have no counterpart. .bb-offcanvas--compact went with the
compact prop, silently: it is not a prop any more, so it lands in $attrs and
changes nothing. And the old close-button token should not be ported at all. The
full mapping is on
BbDialog → Padding and the title and applies
here unchanged.