Drawers (off-canvas)
A panel that slides in over the screen: navigation on narrow shells
(start side) and filters or record previews on wide ones
(--end). It is a native <dialog>
variant, so the focus trap, Esc, backdrop and focus-return come from
the platform.
Not when the panel's content must stay visible beside the
record — sliding over the screen hides the context the user is
comparing against; use
master-detail for that.
Open the drawer — in its own document
An iframe, so the drawer takes over
nothing but itself. .bo-offcanvas is a real
<dialog>: opened inline it renders in the page's top
layer and covers the documentation around it, which makes the one control
you want to study the thing hiding everything else. In its own document the
drawer still gets a real top layer, a real backdrop and real Esc handling —
the platform behaviour is the point — but scoped to the frame.
Start-side drawer — navigation on a narrow shell
Markup
<button class="bo-btn bo-btn--secondary"
data-dialog-trigger="drawer-nav" aria-label="Open navigation">☰ Menu</button>
<dialog class="bo-offcanvas" id="drawer-nav" data-state="closed"
aria-labelledby="drawer-title">
<div class="bo-offcanvas__header">
<span id="drawer-title" class="bo-navbar__brand">busy-office</span>
<form method="dialog">
<button class="bo-btn bo-btn--ghost bo-btn--icon" aria-label="Close">✕</button>
</form>
</div>
<nav class="bo-sidebar-nav" aria-label="Main">…same markup as the sidebar…</nav>
</dialog>
<!-- Opens via initDialogs() — same wiring as any dialog: backdrop, Esc,
focus-return and Tab loop are all inherited. --> End-side drawer — filters & detail panels
--end slides the drawer in from the end
edge — the home for filter panels and record previews (start side stays the
navigation convention). Same dialog wiring, mirrored slide animation, RTL-aware.
Which side, and when
- Start side — navigation, because that is where the sidebar lives; a drawer that replaces the rail should come from where the rail was.
--end— filters, detail previews, anything that supplements the screen you are on rather than taking you elsewhere.- Both mirror correctly under
dir="rtl": "start" and "end" are logical, not left and right.
Quick reference generated
| Class | Role |
|---|---|
| .bo-offcanvas | block (root) |
| .bo-offcanvas--end | variant: end |
| .bo-offcanvas__header | part: header |
| .bo-sidebar-nav | block (root) |
| .bo-sidebar-nav__heading | part: heading |
| .bo-sidebar-nav__label | part: label |
| .bo-sidebar-nav__link | part: link |
State attributes: data-state
API generated from CSS
| Blocks | bo-offcanvasbo-sidebar-nav |
|---|---|
| Parts | bo-offcanvas__headerbo-sidebar-nav__headingbo-sidebar-nav__labelbo-sidebar-nav__link |
| Variants | bo-offcanvas--end |
| data-* hooks | data-state="open" |
| Component tokens | --bo-offcanvas-offset |
| JS required | REQUIRED: initDialogs() — a drawer is a native dialog and never opens without it. It is the same one call as any other dialog, so backdrop, Esc, focus-return and the Tab loop are inherited rather than reimplemented. |
- It is a
<dialog>:initDialogs()wires it exactly like any other — backdrop, Esc, focus-return and the Tab loop are inherited, not reimplemented. - Give it an accessible name with
aria-labelledbypointing at the header title. - A drawer is modal: while it is open the page behind is inert. For a panel that must stay open alongside the content, use a layout column, not a drawer.
Contrast — 15 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-muted | bg-surface | 7.56 | 6.65 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | bg-surface | 6.47 | 8.89 |
| success-text | bg-surface | 5.02 | 12.02 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| border-control | bg-surface | 4.83 | 6.65 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 117 · Firefox 129 · Safari 17.5
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@starting-style — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | no raw font-size |
| Colour | 3 / 3 | ::backdrop now consumes --bo-color-scrim, the shared token added in the 94.6a sweep; zero raw colour, and the scrim is deliberately not theme-remapped (a scrim pushes the page back rather than tinting a surface) |
| Spacing | 3 / 3 | min(18rem, 85vw) explains both halves — wider than the 14rem rail it contains, and capped so a strip of page always shows as the dismiss affordance (94.6b) |
| Interaction | 3 / 3 | reuses the dialog wiring wholesale (backdrop, Esc, focus-return, Tab loop); translate is physical so RTL gets explicit animation overrides |
| Content | 3 / 3 | the opener names the case it must not take — content that has to stay visible beside the record — and sends it to master-detail (94.8) |
| Fit | 3 / 3 | the drawer-nav pattern — a sidebar-nav inside it at phone width; used in 1 pattern screen |