Skip to content

Drawer

Side panel using the native HTML <dialog> element. Slides in from the left or right.

Width comes from --af-drawer-width (default 20rem), capped at 90vw. That is not --af-sidebar-width — the latter sizes in-flow App shell and Sidebar.

Click the button to open a real drawer. ESC, the close control, or the backdrop dismiss it.

Navigation

<button class="af-btn"
onclick="document.getElementById('my-drawer').showModal()">Open Drawer</button>

<dialog class="af-drawer" id="my-drawer"
onclick="if (event.target === this) this.close()">
  <div class="af-drawer-header">
    <h3 class="af-drawer-title">Navigation</h3>
    <button class="af-btn af-btn-icon af-is-sm af-is-outline af-is-secondary" aria-label="Close"
    onclick="this.closest('dialog').close()">×</button>
  </div>
  <div class="af-drawer-body">
    <ul class="af-side-nav">
      <li class="af-side-nav-item">
        <a href="#" class="af-side-nav-link" aria-current="page">Dashboard</a>
      </li>
      <li class="af-side-nav-item">
        <a href="#" class="af-side-nav-link">Settings</a>
      </li>
      <li class="af-side-nav-item">
        <a href="#" class="af-side-nav-link">Account</a>
      </li>
    </ul>
  </div>
</dialog>

Add af-drawer-right to open from the right edge:

Filters

<button class="af-btn"
onclick="document.getElementById('filters-drawer').showModal()">Open Filters</button>

<dialog class="af-drawer af-drawer-right" id="filters-drawer"
onclick="if (event.target === this) this.close()">
  <div class="af-drawer-header">
    <h3 class="af-drawer-title">Filters</h3>
    <button class="af-btn af-btn-icon af-is-sm af-is-outline af-is-secondary" aria-label="Close"
    onclick="this.closest('dialog').close()">×</button>
  </div>
  <div class="af-drawer-body">
    <div class="af-stack af-gap-md">
      <div class="af-field">
        <label class="af-label" for="filter-status">Status</label>
        <select id="filter-status" class="af-select">
          <option>All</option>
          <option>Active</option>
          <option>Archived</option>
        </select>
      </div>
      <div class="af-field">
        <label class="af-label" for="filter-date-range">Date range</label>
        <input id="filter-date-range" class="af-input" type="date">
      </div>
      <button class="af-btn" onclick="this.closest('dialog').close()">Apply Filters</button>
    </div>
  </div>
</dialog>

Add af-has-slide for a CSS-only slide (and backdrop fade). Uses @starting-style + transition-behavior: allow-discrete so native showModal() / close() can animate display — see Browser support below.

Navigation

This drawer slides in from the left.

<button class="af-btn"
onclick="document.getElementById('slide-drawer').showModal()">Open with slide</button>

<dialog class="af-drawer af-has-slide" id="slide-drawer"
onclick="if (event.target === this) this.close()">
  <div class="af-drawer-header">
    <h3 class="af-drawer-title">Navigation</h3>
    <button class="af-btn af-btn-icon af-is-sm af-is-outline af-is-secondary" aria-label="Close"
    onclick="this.closest('dialog').close()">×</button>
  </div>
  <div class="af-drawer-body">
    <p>This drawer slides in from the left.</p>
  </div>
</dialog>

Works with af-drawer-right the same way — the panel slides from the right edge.

--af-drawer-width defaults to 20rem. Override it globally or on one drawer. max-width: 90vw still applies so the panel never exceeds the viewport.

Global:

:root {
  --af-drawer-width: 24rem;
}

On one panel:

Narrow

This panel sets --af-drawer-width: 12rem on the dialog.

<dialog class="af-drawer" style="--af-drawer-width: 12rem">…</dialog>

See Layout tokens for the full set (--af-sidebar-width vs --af-drawer-width).

Same as dialog. Open with showModal(), close with close().

const drawer = document.getElementById('my-drawer');

drawer.showModal();
drawer.close();
ClassPurpose
af-drawerThe <dialog> element. Opens from the left by default. Width: --af-drawer-width.
af-drawer-rightModifier. Opens from the right instead.
af-has-slideOptional. Slide + backdrop fade on open/close.
af-drawer-headerHeader row with title and close button
af-drawer-titleTitle text inside the header
af-drawer-bodyScrollable content area

Use a quiet close control in the header: af-btn af-btn-icon af-is-sm af-is-outline af-is-secondary.

drawer.addEventListener('click', (e) => {
  if (e.target === drawer) drawer.close();
});

Same accessibility model as dialog. Native <dialog> with showModal() marks the page as inert and provides ESC key and body scroll lock. Focus trapping and focus restoration require supplemental JS for full cross-browser reliability.

Motion respects prefers-reduced-motion via Airframe’s base styles (transition durations collapse).

The drawer is a native <dialog> and opens everywhere. Only the optional af-has-slide transition depends on newer CSS:

Feature ChromeEdgeSafariFirefox
@starting-style The opening keyframe to animate from 117 Supported from 117 117 Supported from 117 17.5 Supported from 17.5 129 Supported from 129
transition-behavior: allow-discrete Transitioning display on close 117 Supported from 117 117 Supported from 117 17.4 Supported from 17.4 129 Supported from 129

Safari versions cover iOS Safari too. Without support: the drawer opens and closes instantly instead of sliding. No JavaScript is involved either way. Versions checked against MDN browser-compat-data, August 2026. See Browser Support for Airframe's baseline.