Skip to main contentSkip to docs navigation

Slide-in panels anchored to a viewport edge — built on the native HTML dialog element with placement variants, body scroll control, swipe gestures, and a data-attribute API.

@layer components
Requires JavaScript
Viewport queries
Context tokens

Introduction

A drawer is a <dialog> element styled with .drawer and a placement modifier, activated by the Drawer plugin. The placement modifier determines which edge the panel slides in from; the plugin manages the open/close lifecycle, backdrop, keyboard handling, body scroll, and focus.

The plugin calls showModal() to place the element in the browser's top layer, which provides automatic position: fixed and a native ::backdrop. Body scroll is locked while a drawer is open (configurable via data-cx-scroll). Pressing Escape or clicking the backdrop closes the drawer by default (configurable via data-cx-keyboard and data-cx-backdrop). Touch swipe in the natural direction for each placement also dismisses it. When a second drawer opens while one is already open, the first closes automatically.

Drawer title

Drawer body content goes here.

HTML
<dialog class="drawer drawer-start" id="myDrawer" aria-labelledby="myDrawerLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="myDrawerLabel">Drawer title</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body">
  <p>Drawer body content goes here.</p>
</div>
<div class="drawer-footer">
  <button type="button" class="button primary">Take action</button>
  <button type="button" class="button default" data-cx-dismiss="drawer">Cancel</button>
</div>
</dialog>

Chassis CSS respects user accessibility preferences by automatically disabling animations when the prefers-reduced-motion media query is detected. See the reduced motion guidelines in our accessibility documentation for implementation details.

Basic structure

A drawer is composed of a <dialog> element with .drawer and a placement class, containing up to three region children:

  • .drawer-header — flex row containing the title and an optional .close-button. A direct-child .close-button is positioned absolutely at the block-start / inline-end corner of the header.
  • .drawer-body — main content area. Scrolls independently when the content is taller than the drawer.
  • .drawer-footer — optional flex row, right-aligned, for action buttons.

For decision-forcing overlays that block the current flow, see Alert. For full-height panels that open beside other content, see Modal.

Live example

To open a drawer, add data-cx-toggle="drawer" to any trigger element. For buttons, point at the <dialog> with data-cx-target="#id". For links, set href="#id" instead — data-cx-target is not needed. The close button and Cancel both dismiss it; pressing Escape or clicking the backdrop also closes it by default.

Open drawer

Drawer

Drawer body content goes here.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#exampleDrawerLive">Open drawer</button>
<a href="#exampleDrawerLive" class="button link" data-cx-toggle="drawer">Open drawer</a>

<dialog class="drawer drawer-start" id="exampleDrawerLive" aria-labelledby="exampleDrawerLiveLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="exampleDrawerLiveLabel">Drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body">
  <p>Drawer body content goes here.</p>
</div>
<div class="drawer-footer">
  <button type="button" class="button primary">Take action</button>
  <button type="button" class="button default" data-cx-dismiss="drawer">Cancel</button>
</div>
</dialog>

Placement

The placement modifier determines which viewport edge the panel slides in from. Always add a placement class — the default off-screen transform without one is undefined.

ClassEdge
.drawer-startLeft (LTR) / Right (RTL)
.drawer-endRight (LTR) / Left (RTL)
.drawer-topTop
.drawer-bottomBottom
.drawer-fullscreenAll edges

Start

.drawer-start anchors the panel to the inline-start edge and fills the full block height, sized by --drawer-width.

Start drawer

Slides in from the left (LTR).

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerStart">Start</button>

<dialog class="drawer drawer-start" id="drawerStart" aria-labelledby="drawerStartLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerStartLabel">Start drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Slides in from the left (LTR).</p></div>
</dialog>

End

.drawer-end anchors the panel to the inline-end edge and fills the full block height, sized by --drawer-width.

End drawer

Slides in from the right (LTR).

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerEnd">End</button>

<dialog class="drawer drawer-end" id="drawerEnd" aria-labelledby="drawerEndLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerEndLabel">End drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Slides in from the right (LTR).</p></div>
</dialog>

Top

.drawer-top anchors the panel to the block-start edge and fills the full inline width, sized by --drawer-height.

Top drawer

Slides down from the top.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerTop">Top</button>

<dialog class="drawer drawer-top" id="drawerTop" aria-labelledby="drawerTopLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerTopLabel">Top drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Slides down from the top.</p></div>
</dialog>

Bottom

.drawer-bottom anchors the panel to the block-end edge and fills the full inline width, sized by --drawer-height. Add .drawer-fit-content to size the height to the drawer's content instead of the --drawer-height variable.

Bottom drawer

Slides up from the bottom.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerBottom">Bottom</button>

<dialog class="drawer drawer-bottom" id="drawerBottom" aria-labelledby="drawerBottomLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerBottomLabel">Bottom drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Slides up from the bottom.</p></div>
</dialog>

Behaviors

Drawer behavior and appearance are configurable with data attributes on the <dialog> element or modifier classes applied alongside the placement class.

Body scroll

By default, body scroll is locked while a drawer is open. Set data-cx-scroll="true" on the <dialog> to allow the page to scroll while the drawer is open. Combine with data-cx-backdrop="false" to remove the backdrop entirely.

Scrolling, no backdrop

Body scroll is enabled and the backdrop is removed.

Scrolling with backdrop

Body scroll is enabled and the backdrop remains visible.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerScrolling">Scrolling, no backdrop</button>
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerScrollBackdrop">Scrolling with backdrop</button>

<dialog class="drawer drawer-start" data-cx-scroll="true" data-cx-backdrop="false" id="drawerScrolling" aria-labelledby="drawerScrollingLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerScrollingLabel">Scrolling, no backdrop</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Body scroll is enabled and the backdrop is removed.</p></div>
</dialog>

<dialog class="drawer drawer-start" data-cx-scroll="true" id="drawerScrollBackdrop" aria-labelledby="drawerScrollBackdropLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerScrollBackdropLabel">Scrolling with backdrop</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Body scroll is enabled and the backdrop remains visible.</p></div>
</dialog>

Static backdrop

data-cx-backdrop="static" prevents closing on backdrop click — the drawer nudges briefly to signal it requires explicit action. Add data-cx-keyboard="false" to also disable Escape.

Static backdrop

Clicking outside nudges this drawer rather than closing it.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerStatic">Static backdrop</button>

<dialog class="drawer drawer-start" data-cx-backdrop="static" id="drawerStatic" aria-labelledby="drawerStaticLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerStaticLabel">Static backdrop</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Clicking outside nudges this drawer rather than closing it.</p></div>
</dialog>

Animation

Drawers animate in and out by default. Add .instant to skip all transitions.

Instant drawer

No transition — opens and closes immediately.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerInstant">Open instant drawer</button>

<dialog class="drawer drawer-start instant" id="drawerInstant" aria-labelledby="drawerInstantLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerInstantLabel">Instant drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>No transition — opens and closes immediately.</p></div>
</dialog>

Appearance

.sheet removes the inset gap, border radius, and border — the panel sits flush against the viewport edge. .translucent applies a frosted-glass background. Both modifiers combine freely with placement and behavior classes.

Sheet drawer

Flush against the viewport edge — no inset, rounding, or border.

Translucent drawer

Frosted-glass background over the page content.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerSheet">Sheet</button>
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerTranslucent">Translucent</button>

<dialog class="drawer drawer-start sheet" id="drawerSheet" aria-labelledby="drawerSheetLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerSheetLabel">Sheet drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Flush against the viewport edge — no inset, rounding, or border.</p></div>
<div class="drawer-footer">
  <button type="button" class="button neutral" data-cx-dismiss="drawer">Close</button>
</div>
</dialog>

<dialog class="drawer drawer-start translucent" id="drawerTranslucent" aria-labelledby="drawerTranslucentLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerTranslucentLabel">Translucent drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Frosted-glass background over the page content.</p></div>
<div class="drawer-footer">
  <button type="button" class="button neutral" data-cx-dismiss="drawer">Close</button>
</div>
</dialog>

Fullscreen

.fullscreen expands the panel to fill the viewport, animating in and out from the direction of its placement class. Combine with .sheet or .translucent to adjust the appearance.

Fullscreen drawer

Fills the full viewport inset area.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#drawerFullscreen">Fullscreen</button>

<dialog class="drawer drawer-bottom fullscreen" id="drawerFullscreen" aria-labelledby="drawerFullscreenLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="drawerFullscreenLabel">Fullscreen drawer</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body"><p>Fills the full viewport inset area.</p></div>
</dialog>

The .drawer-footer uses flex-direction: row-reverse, so buttons are right-aligned without justify-content — write the primary action first in source, it renders on the right. At the small breakpoint, buttons stretch to equal widths (flex: 1). Add .stacked to stack them as full-width columns instead.

Drawer with stacked actions

A drawer with multiple footer actions, stacking full-width at the small breakpoint.

HTML
<button type="button" class="button primary" data-cx-toggle="drawer" data-cx-target="#exampleDrawerFooter">Open drawer</button>

<dialog class="drawer drawer-start" id="exampleDrawerFooter" aria-labelledby="exampleDrawerFooterLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="exampleDrawerFooterLabel">Drawer with stacked actions</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body">
  <p>A drawer with multiple footer actions, stacking full-width at the small breakpoint.</p>
</div>
<div class="drawer-footer stacked">
  <button type="button" class="button primary">Take action</button>
  <button type="button" class="button default" data-cx-dismiss="drawer">Cancel</button>
</div>
</dialog>

Responsive

Responsive drawer classes behave as drawers below the named breakpoint and display their contents inline above it. This is the basis for navigation that collapses off-screen on narrow viewports and expands inline on wide ones — the Navbar uses this pattern for its collapsible menu region.

ClassBehavior
.drawerAlways a drawer
.max-small:drawerDrawer below small, inline above
.max-medium:drawerDrawer below medium, inline above
.max-large:drawerDrawer below large, inline above
.max-xlarge:drawerDrawer below xlarge, inline above
.max-2xlarge:drawerDrawer below 2xlarge, inline above

Above the breakpoint the element resets to a flex container: .drawer-header is hidden (the trigger button is typically hidden with a matching utility class) and .drawer-body renders as a flex row within the parent layout.

Navigation

This is a .max-large:drawer — a drawer on small screens, inline content on large screens.

HTML
<button class="button primary large:d-none" type="button" data-cx-toggle="drawer" data-cx-target="#responsiveDrawer">Open drawer</button>

<dialog class="max-large:drawer drawer-end" id="responsiveDrawer" aria-labelledby="responsiveDrawerLabel">
<div class="drawer-header">
  <h2 class="drawer-title" id="responsiveDrawerLabel">Navigation</h2>
  <button type="button" class="close-button" data-cx-dismiss="drawer" aria-label="Close"></button>
</div>
<div class="drawer-body">
  <p>This is a <code>.max-large:drawer</code> — a drawer on small screens, inline content on large screens.</p>
</div>
</dialog>

Accessibility

The <dialog> element carries an implicit role="dialog" when opened with showModal(). Provide an accessible name by placing aria-labelledby on the <dialog> pointing at the .drawer-title element's id.

Focus moves to the <dialog> element on open. To move focus to a specific element on open, add autofocus to it or listen to shown.cx.drawer:

JavaScript
document.getElementById('myDrawer').addEventListener('shown.cx.drawer', () => {
  document.getElementById('myInput').focus()
})

After the drawer closes, focus returns to the element that triggered the open.

Touch swipe gestures dismiss the drawer toward the edge it entered from: swipe left for .drawer-start, swipe right for .drawer-end, swipe up for .drawer-top, swipe down for .drawer-bottom. Keyboard users rely on the Escape key or an explicit close button — ensure at least one dismiss path is available when both data-cx-backdrop="static" and data-cx-keyboard="false" are set.

Body scroll is locked while a drawer is open (dialog-open is added to <body>). The lock releases only when all open drawers are closed.

JavaScript API

The Drawer plugin opens and closes the <dialog> element and manages the backdrop, body-scroll lock, swipe gestures, and transition lifecycle. Chassis JS ships as an ES module — import the Drawer class:

JavaScript
import { Drawer } from '@chassis-ui/css'

Triggers

The data-attribute API opens and closes drawers without writing any custom JavaScript:

HTML
<!-- Trigger button -->
<button data-cx-toggle="drawer" data-cx-target="#myDrawer">Open</button>

<!-- Close button inside the drawer -->
<button data-cx-dismiss="drawer" aria-label="Close"></button>

<!-- Optional config on the dialog element -->
<dialog class="drawer drawer-start"
        data-cx-backdrop="static"
        data-cx-keyboard="false">
  ...
</dialog>
AttributeValuesDescription
data-cx-toggle"drawer"Marks the trigger element.
data-cx-targetCSS selectorThe <dialog> to toggle.
data-cx-backdroptrue, false, "static"Overrides the backdrop option.
data-cx-keyboardtrue, falseOverrides the keyboard option.
data-cx-scrolltrue, falseOverrides the scroll option.

Initialization

For programmatic access — calling methods or listening to events — instantiate each element with the Drawer class:

JavaScript
const drawer = new Drawer('#myDrawer')

// With options
const drawer = new Drawer('#myDrawer', {
  backdrop: 'static',
  keyboard: false,
  scroll: true
})

Options

Options are set via data-cx-* attributes or passed to the constructor as an object. Attribute names use the kebab-case form of the option name — data-cx-custom-class, not data-cx-customClass. Attribute values are parsed to their native types:"true"true, "0"0, and valid JSON strings to objects.

Use data-cx-config to pass multiple options as a JSON string:data-cx-config='{"delay":200}'. Individual data-cx-* attributes take precedence over data-cx-config. You can also use JSON values in individual attributes, such as data-cx-delay='{"show":100,"hide":200}'.

When initializing components, Chassis merges configurations from multiple sources in this priority order: default settings, data-cx-config values, individual data-cx-*attributes, and finally any JavaScript object options. Values defined later in this sequence override earlier ones.

OptionTypeDefaultDescription
backdropboolean | 'static'trueShow a backdrop while open. 'static' prevents closing on backdrop click.
keyboardbooleantrueClose on Escape key.
scrollbooleanfalseAllow body scroll while the drawer is open.

Methods

All Chassis CSS component methods are asynchronous and initiate CSS transitions. Methods return immediately when the transition begins, not when it completes. Calling methods on components that are already transitioning will be ignored to prevent conflicts. Learn more about Chassis JavaScript patterns.

The plugin exposes the following instance methods. show() and hide() return before the transition completes — listen to the shown and hidden events for post-transition timing.

MethodDescription
show()Opens the drawer.
hide()Closes the drawer.
toggle()Toggles open or closed.
dispose()Removes the instance and its event listeners.
getInstance()Static. Returns the instance for an element, or null.
getOrCreateInstance()Static. Returns the existing instance or creates one.
JavaScript
const drawer = new Drawer('#myDrawer')

drawer.show()
drawer.hide()
drawer.toggle()
drawer.dispose()

const instance = Drawer.getInstance('#myDrawer')
const instance = Drawer.getOrCreateInstance('#myDrawer')

Events

All events fire on the <dialog> element. Cancelable events (show.cx.drawer, hide.cx.drawer) can be prevented with event.preventDefault() to block the action.

EventDescription
show.cx.drawerFires immediately when show() is called. Cancelable. event.relatedTarget is the trigger element.
shown.cx.drawerFires after the entry transition completes and the drawer is fully visible.
hide.cx.drawerFires immediately when hide() is called. Cancelable.
hidden.cx.drawerFires after the exit transition completes and the drawer is fully hidden.
hidePrevented.cx.drawerFires when a close attempt is blocked (static backdrop click or keyboard: false).
JavaScript
const el = document.getElementById('myDrawer')

el.addEventListener('show.cx.drawer', event => {
  const trigger = event.relatedTarget
  if (trigger) console.log('Opened by:', trigger.id)
})

el.addEventListener('shown.cx.drawer', () => {
  el.querySelector('[autofocus]')?.focus()
})

el.addEventListener('hidden.cx.drawer', () => {
  resetTemporaryStates()
})

CSS

The Drawer component can be customized through Sass variables at compile time.

Custom properties

The Drawer component exposes CSS custom properties to control its appearance at runtime.

--zindex: var(--drawer-zindex, #{$zindex-drawer});
--inset: var(--drawer-inset, #{$drawer-inset});
--width: var(--drawer-width, #{$drawer-width});
--height: var(--drawer-height, #{$drawer-height});
--padding-x: var(--drawer-padding-x, #{$drawer-padding-x});
--padding-y: var(--drawer-padding-y, #{$drawer-padding-y});
--gap: var(--drawer-gap, #{$drawer-gap});
--footer-large-gap: var(--drawer-footer-large-gap, #{$drawer-footer-large-gap});
--footer-small-gap: var(--drawer-footer-small-gap, #{$drawer-footer-small-gap});
--fg-color: var(--drawer-fg-color, #{$drawer-fg-color});
--bg-color: var(--drawer-bg-color, #{$drawer-bg-color});
--border-color: var(--drawer-border-color, #{$drawer-border-color});
--border-width: var(--drawer-border-width, #{$drawer-border-width});
--border-radius: var(--drawer-border-radius, #{$drawer-border-radius});
--box-shadow: var(--drawer-box-shadow, #{$drawer-box-shadow});
@include map-font($drawer-title-font, drawer-title, title);
--transition-duration: var(--drawer-transition-duration, #{$drawer-transition-duration});
--transition-timing: var(--drawer-transition-timing, #{$drawer-transition-timing});
--backdrop-bg: var(--drawer-backdrop-bg, #{$drawer-backdrop-bg});
--backdrop-opacity: var(--drawer-backdrop-opacity, #{$drawer-backdrop-opacity});
--backdrop-blur: var(--drawer-backdrop-blur, #{$drawer-backdrop-blur});

Sass variables

The Drawer component uses Sass variables in scss/config/_defaults.scss to define its defaults.

// $drawer-z-index:                $zindex-drawer;
$drawer-inset:                      var(--space-medium);
$drawer-padding-y:                  $modal-medium-padding-y;
$drawer-padding-x:                  $modal-medium-padding-x;
$drawer-gap:                        $modal-medium-gap;
$drawer-footer-large-gap:           $modal-footer-large-gap;
$drawer-footer-small-gap:           $modal-footer-small-gap;
$drawer-width:                      25rem;
$drawer-height:                     30vh;
$drawer-transition-duration:        .3s;
$drawer-transition-timing:          cubic-bezier(.22, 1, .36, 1);
$drawer-border-color:               $modal-border-color;
$drawer-border-width:               $modal-border-width;
$drawer-border-radius:              $modal-border-radius;
$drawer-title-font:                 $font-text-large-mass;
$drawer-bg-color:                   var(--bg-main);
$drawer-fg-color:                   var(--fg-main);
$drawer-box-shadow:                 $modal-box-shadow;
$drawer-backdrop-bg:                var(--black-dim-main);
$drawer-backdrop-opacity:           25%;
$drawer-backdrop-blur:              8px;