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

Preferences

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.

Drop form fields, navigation links, or any long-form content inside. Native focus trapping handles Tab / Shift+Tab within the drawer.

<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 (h2h6, 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.

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.

Navigation

Slide-from-start drawer (default). Common for mobile nav.

Filters

Slide-from-end drawer. Common for filter panels and detail drawers.

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

Small

Compact nav.

Large

Form-shaped drawer.

Extra large

Content-heavy panel.

<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() (via command="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), add aria-label directly to the <dialog>.
  • Dismiss needs a name. The × glyph on the dismiss button isn't announced meaningfully -- always add aria-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 set transition: none on .drawer-panel and .drawer::backdrop.

CSS Reference: Drawer

Sliding side panel overlay using native dialog

Source: src/css/drawer.css

Tokens

TokenDefaultDescription
--drawer-width18remWidth of the drawer panel
--drawer-bgvar(--color-surface)Background color for the panel
--drawer-bordervar(--border-width-default) solid var(--color-border)Outer border for the panel edge facing the page
--drawer-header-bordervar(--border-width-default) solid var(--color-border)Separator border below the header
--drawer-footer-bordervar(--border-width-default) solid var(--color-border)Separator border above the footer
--drawer-text-colorvar(--color-text)Text color inside the panel
--drawer-radiusvar(--radius-sm)Border radius on the panel's page-facing corners
--drawer-title-sizevar(--ui-text-base)Font size for the drawer title heading

Slots

SlotDescription
.drawer-panelVisible sliding container pinned to one edge
.drawer-panel > headerTitle row with bordered separator
.drawer-panel > :is(section, article)scrollable middle, grows to fill the panel column
.drawer-panel > footerright-aligned action row, pinned to the bottom

Classes

ClassDescription
.drawercomponent root
.is-closingAnimated close state applied by brio-drawer before dialog.close()
.is-endSlides from the end (right in LTR) instead of the start
.no-borderStrips all borders from the drawer and its slots
.is-smNarrow drawer at 14rem
.is-lgWide drawer at 24rem
.is-xlExtra-wide drawer at 32rem