Skip to content

BbOffCanvas

The panel that slides in from an edge, sized by extent rather than width.

import { BbOffCanvas } from 'bitboss-ui';

On this page

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 else
  • BbPopover: the panel is anchored to the control that opened it
  • BbDropdown: the anchored content is a menu of actions
  • BbConfirm: 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:

vue
<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 as data, and useBbOffCanvasContext<T>(id) returns it typed, with open, isReady, show(data?), hide(), complete(value) and reset(). A show() 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: the id is also the DOM id.
  • complete(value) closes with a result for the trigger's onComplete (or await show(), as on the dialog), and hide() closes with none; both work even under prevent-close. The slots' close() is the user's close.
  • The page can own the save. An onComplete that 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.
  • @hide carries the opening's result, a swipe included. To tell a save from a cancel, close a save with complete(), not by writing the model: on a v-model panel too it reports { completed: true }.
  • A v-model on 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

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.

The choice is not only cosmetic. The side also decides:

  • which dimension size controls
  • 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:

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

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.

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.

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:

ts
// 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.

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

  • 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 = 1 above) moves it too.
  • An adaptive BbDialog passes snap-points and v-model:snap to 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.

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.

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):

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

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