Drawer
Side-panel overlay
Intent
Drawer is a side-panel overlay. Use it when modal content
is long enough to benefit from a column rather than a
centered box -- navigation, settings forms, filter panels,
mobile menus, detail views that can scroll. Like
dialog, drawer builds on
the native <dialog> element to get
focus trapping and backdrop semantics for free, and adds
a small custom element
(<brio-drawer>) for an animated close.
Reach for dialog when the content is compact and modal --
a confirmation, a small form, a detail preview. Reach for
drawer when the content benefits from a tall, narrow
column -- especially on mobile where a full-width modal
feels heavy. The two components share invoker syntax
(commandfor / command), so
switching between them later is a markup change, not a
logic change.
Basic usage
A drawer needs three things: the
<brio-drawer> wrapper (for command
handling and the animated close), the native
<dialog class="drawer"> with a stable
id, and a nested
<div class="drawer-panel"> that holds
the actual sliding content (header, body, footer).
<brio-drawer>
<dialog class="drawer" id="demo-drawer-basic">
<div class="drawer-panel">
<header>
<h3>Preferences</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-basic" command="--dismiss" aria-label="Close">×</button>
</header>
<section>
<p>Drawer body content sits here. The body grows to fill remaining vertical space and scrolls when content overflows, so headers and footers stay pinned at the edges.</p>
<p>Drop form fields, navigation links, or any long-form content inside. Native focus trapping handles Tab / Shift+Tab within the drawer.</p>
</section>
<footer>
<button class="button is-outlined" type="button" commandfor="demo-drawer-basic" command="--dismiss">Cancel</button>
<button class="button" type="button" commandfor="demo-drawer-basic" command="--dismiss">Save</button>
</footer>
</div>
</dialog>
</brio-drawer>
<button class="button" type="button" commandfor="demo-drawer-basic" command="show-modal">Open drawer</button>
The dialog itself is a full-viewport container with a
transparent background; the visible panel is
.drawer-panel, positioned fixed to one edge
and animated via transform. That separation lets the
backdrop handle click-to-dismiss at the viewport level
while the panel drives the slide transition.
Slots
The panel is a structural class; the inner regions
(<header> /
<section> /
<footer>) are keyed by element. A
nested header/footer inside body content stays at UA
defaults.
.drawer-panel
The visible sliding container. Flex column so header, body,
and footer stack naturally; pinned to an edge via
position: fixed and
inset-inline-start: 0 (or end for
.is-end). The panel's width is set by
--drawer-width but capped so there's always some
backdrop visible on narrow viewports.
Drawer keeps .drawer-panel as a class because
the dialog/panel split is a structural archetype: the
dialog catches backdrop clicks at the viewport level while
the panel carries the slide animation, radius, shadow, and
border. There's no native HTML element that means "the
visible inner panel of a modal," and the slide animation in
brio-drawer.js targets this element by class.
Header (<header>)
A
<header>
as direct child of the panel. Flex row; pair a heading
(h2–h6, author's choice per the
drawer's content hierarchy) with a .dismiss
button. The CSS gives the header
justify-content: space-between so the title
sits left and the dismiss sits right.
flex-shrink: 0 keeps the header at its content
height even when the body scrolls. Heading visual size
normalizes via --drawer-title-size.
Body (<section> or <article>)
A
<section>
is the canonical fit -- "thematic grouping of content."
An
<article>
is also accepted for self-contained compositions. The body
is the scrollable middle:
flex: 1 grows it to fill remaining space after
header and footer claim their heights;
overflow-y: auto lets long content scroll
inside the panel rather than pushing the footer
off-screen.
Footer (<footer>)
A
<footer>
as direct child of the panel. Bottom action row. Same
flex-right structure as dialog's footer -- optional,
mostly used for confirm / cancel pairs on form-style
drawers. For a navigation drawer with no actions, omit
the footer entirely.
Variants
Side
Drawers slide from the inline-start edge by default (left
in LTR writing modes). .is-end flips the
transform and border / radius to slide from the
inline-end edge. Mobile nav typically uses start; filter
panels and detail drawers typically use end.
<div>
<brio-drawer>
<dialog class="drawer" id="demo-drawer-start">
<div class="drawer-panel">
<header>
<h3>Navigation</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-start" command="--dismiss" aria-label="Close">×</button>
</header>
<section>
<p>Slide-from-start drawer (default). Common for mobile nav.</p>
</section>
</div>
</dialog>
</brio-drawer>
<brio-drawer>
<dialog class="drawer is-end" id="demo-drawer-end">
<div class="drawer-panel">
<header>
<h3>Filters</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-end" command="--dismiss" aria-label="Close">×</button>
</header>
<section>
<p>Slide-from-end drawer. Common for filter panels and detail drawers.</p>
</section>
</div>
</dialog>
</brio-drawer>
<div class="cluster is-tight">
<button class="button is-outlined" type="button" commandfor="demo-drawer-start" command="show-modal">Open start drawer</button>
<button class="button is-outlined" type="button" commandfor="demo-drawer-end" command="show-modal">Open end drawer</button>
</div>
</div>Size
Width variants resolve through the --drawer-width
token. .is-sm for compact nav; the default for
typical navigation and filter lists; .is-lg for
forms; .is-xl for content-heavy panels. All widths
cap so narrow viewports always keep some page visible behind
the drawer.
<div>
<brio-drawer>
<dialog class="drawer is-sm" id="demo-drawer-sm">
<div class="drawer-panel">
<header>
<h3>Small</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-sm" command="--dismiss" aria-label="Close">×</button>
</header>
<section><p>Compact nav.</p></section>
</div>
</dialog>
</brio-drawer>
<brio-drawer>
<dialog class="drawer is-lg" id="demo-drawer-lg">
<div class="drawer-panel">
<header>
<h3>Large</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-lg" command="--dismiss" aria-label="Close">×</button>
</header>
<section><p>Form-shaped drawer.</p></section>
</div>
</dialog>
</brio-drawer>
<brio-drawer>
<dialog class="drawer is-xl" id="demo-drawer-xl">
<div class="drawer-panel">
<header>
<h3>Extra large</h3>
<button class="dismiss" type="button" commandfor="demo-drawer-xl" command="--dismiss" aria-label="Close">×</button>
</header>
<section><p>Content-heavy panel.</p></section>
</div>
</dialog>
</brio-drawer>
<div class="cluster is-tight">
<button class="button is-outlined" type="button" commandfor="demo-drawer-sm" command="show-modal">Open .is-sm</button>
<button class="button is-outlined" type="button" commandfor="demo-drawer-lg" command="show-modal">Open .is-lg</button>
<button class="button is-outlined" type="button" commandfor="demo-drawer-xl" command="show-modal">Open .is-xl</button>
</div>
</div>State and interaction
Drawer's invoker patterns match dialog's -- same
command / commandfor attributes,
different command names to distinguish animated from
instant close:
- Open:
command="show-modal"-- opens the drawer with the entry animation (slide + backdrop fade). - Close (animated):
command="--dismiss"-- custom command intercepted by<brio-drawer>to run the slide-out animation before the dialog closes. Use this from dismiss buttons and action buttons inside the drawer so the panel slides away rather than disappearing. - Close (instant):
command="close"-- native close; skips the animation. Rarely used directly; reserve for cases where an animation would feel wrong (a toast-like quick confirmation, a route change that makes the drawer momentarily stale). - Escape: intercepted by
<brio-drawer>so the close animates instead of snapping. - Backdrop click: always closes with animation -- drawers are dismissible by nature; there's no non-dismissible mode.
Programmatic API. Expose via
brio.drawer.open(id) and
brio.drawer.close(id) when you need to
open / close from JavaScript (route changes, timer-based
auto-dismiss, test harnesses).
Animation
Entry animation uses @starting-style to
declare the off-screen starting position of the panel and
zero-opacity backdrop. Exit animation is handled by
<brio-drawer> via the
animated-close helper: when a close is
triggered, the element adds .is-closing to
the dialog; the CSS's .drawer.is-closing
.drawer-panel rule transitions the panel back
off-screen; when the transition completes, the native
.close() runs.
.is-closing is one of the rare cases where
a .is-* class drives interactive state (per
P5's "transient /
animation-specific state with no semantic meaning"
exception). It exists only during the exit animation; no
other code should read or rely on it.
Graceful degradation. Browsers without
@starting-style or discrete transitions skip
the animation and fall back to instant show / hide via
the native [open] attribute. The widget
remains fully operational.
Reference
.drawer-- the styled<dialog>container. Pair with<brio-drawer>for animated close and backdrop / Escape handling..drawer-panel-- the sliding visible element inside the dialog. Carries the slide animation, radius, shadow, and border. Kept as a class because no native element fits.<header>(direct child of panel) -- title row with bordered separator. Slot keyed by element.<section>or<article>(direct child of panel) -- scrollable middle (flex: 1). Slot keyed by element.<footer>(direct child of panel) -- bottom action row. Slot keyed by element..is-end-- slide from the inline-end edge instead of start..is-sm,.is-lg,.is-xl-- width variants..is-closing-- transient class applied during the exit animation; handled by<brio-drawer>, not authored..no-border-- opt out of the drawer, header, and footer borders together.
Customization
Drawer exposes scoped tokens for panel chrome (width,
background, text color) and three border shorthands (panel,
header, footer) -- the same three-border design as
dialog, where
.no-border strips all three together and
consumers can style any one independently. Size variants remap
only --drawer-width. The panel's width always
caps below the viewport width so narrow viewports keep a strip
of backdrop visible regardless of which size is applied.
Full token list and defaults:
src/css/drawer.css.
Accessibility
-
Focus trap is native.
<dialog>.showModal()(viacommand="show-modal") traps keyboard focus inside the drawer. Tab cycles through the panel's interactive elements; focus returns to the invoker on close. No custom focus-management code required. -
Title is the label. The heading
inside the
<header>slot is the drawer's announced label. Keep it short and descriptive. If your design needs no visible title (rare; mobile nav drawers sometimes omit the header), addaria-labeldirectly to the<dialog>. -
Dismiss needs a name. The
×glyph on the dismiss button isn't announced meaningfully -- always addaria-label="Close"(or a localized equivalent). - Don't trap content behind a drawer. Drawer is modal -- while open, the rest of the page is inert. Don't put critical information in a drawer the user might dismiss without reading (a required disclosure, a consent notice); those belong in-page or in a non-dismissible dialog.
-
Motion preference. Drawer's slide
and backdrop fade are not currently gated by
prefers-reduced-motion. Consumers whose audience warrants it can add a@media (prefers-reduced-motion: reduce)rule to settransition: noneon.drawer-paneland.drawer::backdrop.
CSS Reference: Drawer
Sliding side panel overlay using native dialog
Source: src/css/drawer.css
Tokens
| Token | Default | Description |
|---|---|---|
--drawer-width | 18rem | Width of the drawer panel |
--drawer-bg | var(--color-surface) | Background color for the panel |
--drawer-border | var(--border-width-default) solid var(--color-border) | Outer border for the panel edge facing the page |
--drawer-header-border | var(--border-width-default) solid var(--color-border) | Separator border below the header |
--drawer-footer-border | var(--border-width-default) solid var(--color-border) | Separator border above the footer |
--drawer-text-color | var(--color-text) | Text color inside the panel |
--drawer-radius | var(--radius-sm) | Border radius on the panel's page-facing corners |
--drawer-title-size | var(--ui-text-base) | Font size for the drawer title heading |
Slots
| Slot | Description |
|---|---|
.drawer-panel | Visible sliding container pinned to one edge |
.drawer-panel > header | Title row with bordered separator |
.drawer-panel > :is(section, article) | scrollable middle, grows to fill the panel column |
.drawer-panel > footer | right-aligned action row, pinned to the bottom |
Classes
| Class | Description |
|---|---|
.drawer | component root |
.is-closing | Animated close state applied by brio-drawer before dialog.close() |
.is-end | Slides from the end (right in LTR) instead of the start |
.no-border | Strips all borders from the drawer and its slots |
.is-sm | Narrow drawer at 14rem |
.is-lg | Wide drawer at 24rem |
.is-xl | Extra-wide drawer at 32rem |