Skip to content

Component

drawer

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and drawer.css. No scripts.

Source

Copy both files, or run npx nojsui add drawer.

Source for drawer
<!-- .sk-drawer — a modal <dialog> pinned to a viewport edge.

     Same mechanics as .sk-dialog: Invoker Commands open and close it, and
     <dialog> supplies modality, focus containment and focus return. The only
     difference is placement, chosen with data-side. -->
<div class="sk-drawer__triggers">
  <button
    type="button"
    class="sk-drawer__trigger"
    command="show-modal"
    commandfor="sk-drawer-side"
  >
    Open side drawer
  </button>

  <button
    type="button"
    class="sk-drawer__trigger"
    command="show-modal"
    commandfor="sk-drawer-sheet"
  >
    Open bottom sheet
  </button>
</div>

<dialog
  id="sk-drawer-side"
  class="sk-drawer"
  data-side="inline-end"
  aria-labelledby="sk-drawer-side-title"
>
  <header class="sk-drawer__header">
    <h2 id="sk-drawer-side-title" class="sk-drawer__title">Filters</h2>
    <button
      type="button"
      class="sk-drawer__close"
      command="close"
      commandfor="sk-drawer-side"
      aria-label="Close filters"
    >
      <span aria-hidden="true">&times;</span>
    </button>
  </header>

  <div class="sk-drawer__body">
    <p class="sk-drawer__text">
      Pinned to the inline-end edge, so it flips to the other side in a
      right-to-left document without any extra CSS.
    </p>
  </div>
</dialog>

<dialog
  id="sk-drawer-sheet"
  class="sk-drawer"
  data-side="block-end"
  aria-labelledby="sk-drawer-sheet-title"
>
  <header class="sk-drawer__header">
    <h2 id="sk-drawer-sheet-title" class="sk-drawer__title">Share</h2>
    <button
      type="button"
      class="sk-drawer__close"
      command="close"
      commandfor="sk-drawer-sheet"
      aria-label="Close share sheet"
    >
      <span aria-hidden="true">&times;</span>
    </button>
  </header>

  <div class="sk-drawer__body">
    <p class="sk-drawer__text">
      There is no swipe-to-dismiss: CSS cannot close a dialog, and a gesture
      that only hides it would leave the page inert behind a sheet the user
      thinks is gone. See ADR 0006.
    </p>
  </div>
</dialog>
/* .sk-drawer — a modal <dialog> pinned to a viewport edge.

   Modality, focus containment, Esc and focus return all come from <dialog>
   itself, exactly as in .sk-dialog. This file only places the panel and slides
   it in. There is no swipe-to-dismiss; ADR 0006 explains why.

   Placement is plain logical margins, not position-area: a drawer is pinned to
   the viewport, and position-area needs an anchor element. A top-layer dialog's
   containing block is already the viewport, so `margin-inline-start: auto` is
   all "pin to the inline-end edge" takes — and it flips in RTL for free.

   Specs:
   - <dialog> ............... https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
   - invoker commands ....... https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-command
   - :dir() ................. https://drafts.csswg.org/selectors-4/#the-dir-pseudo
   - @starting-style ........ https://drafts.csswg.org/css-transitions-2/#defining-before-change-style
   - transition-behavior .... https://drafts.csswg.org/css-transitions-2/#transition-behavior-property */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. Like .sk-dialog this component has no
   single root — the panel is a top-layer <dialog> and the triggers sit beside
   it — so the aliases are declared on both. */
.sk-drawer,
.sk-drawer__triggers {
  --_bg: var(--sk-drawer-bg, var(--sk-color-surface-raised));
  --_fg: var(--sk-drawer-fg, var(--sk-color-text));
  --_shadow: var(--sk-drawer-shadow, var(--sk-shadow-lg));
  --_radius: var(--sk-drawer-radius, var(--sk-radius-lg));
  --_inline-size: var(--sk-drawer-inline-size, 22rem);
  --_sheet-max-block-size: var(--sk-drawer-sheet-max-block-size, 80%);
  --_divider: var(--sk-drawer-divider-color, var(--sk-color-border));
  --_close-fg: var(--sk-drawer-close-fg, var(--sk-color-text-muted));
  --_close-bg-hover: var(--sk-drawer-close-bg-hover, var(--sk-color-surface));
  --_button-bg: var(--sk-drawer-button-bg, var(--sk-color-surface));
  --_button-bg-hover: var(--sk-drawer-button-bg-hover, var(--sk-color-surface-raised));
  --_button-fg: var(--sk-drawer-button-fg, var(--sk-color-text));
  --_button-border: var(--sk-drawer-button-border-color, var(--sk-color-border-strong));
  --_button-radius: var(--sk-drawer-button-radius, var(--sk-radius-md));
}

.sk-drawer__triggers {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-xs);
  padding-block: var(--sk-space-md);
  padding-inline: var(--sk-space-md);
}

.sk-drawer__trigger {
  border: var(--sk-border-width) solid var(--_button-border);
  border-radius: var(--_button-radius);
  background: var(--_button-bg);
  color: var(--_button-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

.sk-drawer__trigger:hover {
  background: var(--_button-bg-hover);
}

/* ---------------------------------------------------------------------------
   The panel
--------------------------------------------------------------------------- */

.sk-drawer {
  /* The UA centres a modal dialog with margin: auto. Zeroing it and then
     setting one side back to auto is what pins the panel to an edge. */
  margin: 0;
  padding: 0;
  border: 0;
  max-inline-size: none;
  max-block-size: none;
  background: var(--_bg);
  color: var(--_fg);
  box-shadow: var(--_shadow);

  /* Slide distance, flipped per side below. Private: it is derived from which
     edge the panel is pinned to, not a knob (ADR 0011). */
  --_slide: 0;
}

.sk-drawer[data-side="inline-start"],
.sk-drawer[data-side="inline-end"] {
  block-size: 100%;
  inline-size: min(var(--_inline-size), calc(100vi - var(--sk-space-xl)));
}

.sk-drawer[data-side="inline-start"] {
  margin-inline-end: auto;
  border-start-end-radius: var(--_radius);
  border-end-end-radius: var(--_radius);
  --_slide: -100%;
}

.sk-drawer[data-side="inline-end"] {
  margin-inline-start: auto;
  border-start-start-radius: var(--_radius);
  border-end-start-radius: var(--_radius);
  --_slide: 100%;
}

.sk-drawer[data-side="block-end"] {
  margin-block-start: auto;
  inline-size: 100%;
  max-block-size: var(--_sheet-max-block-size);
  border-start-start-radius: var(--_radius);
  border-start-end-radius: var(--_radius);
}

/* `translate` is physical, so an inline-side drawer has to know which way is
   "out" when the document is right-to-left. The block-end sheet is unaffected.
   No @supports guard: :dir() is Baseline widely available. */
.sk-drawer[data-side="inline-start"]:dir(rtl) {
  --_slide: 100%;
}

.sk-drawer[data-side="inline-end"]:dir(rtl) {
  --_slide: -100%;
}

.sk-drawer__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sk-space-sm);
  padding-block: var(--sk-space-md);
  padding-inline: var(--sk-space-lg);
  border-block-end: var(--sk-border-width) solid var(--_divider);
}

.sk-drawer__title {
  font-size: var(--sk-text-lg);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-drawer__close {
  border: 0;
  border-radius: var(--sk-radius-full);
  background: none;
  color: var(--_close-fg);
  font-size: var(--sk-text-xl);
  line-height: 1;
  min-inline-size: var(--sk-size-tap-target);
  min-block-size: var(--sk-size-tap-target);
}

.sk-drawer__close:hover {
  background: var(--_close-bg-hover);
  color: var(--_fg);
}

.sk-drawer__body {
  padding-block: var(--sk-space-md);
  padding-inline: var(--sk-space-lg);
  overflow-y: auto;
}

.sk-drawer__text {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

/* ---------------------------------------------------------------------------
   Motion

   Opt-in only. Without it the drawer appears and disappears in place, which is
   complete. base.css also clamps durations globally under `reduce`, so the
   slide is not merely reduced there — it does not run at all.
--------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  .sk-drawer {
    opacity: 0;
    transition:
      opacity var(--sk-motion-fast) var(--sk-ease-out),
      translate var(--sk-motion-base) var(--sk-ease-out);
  }

  .sk-drawer[data-side="inline-start"],
  .sk-drawer[data-side="inline-end"] {
    translate: var(--_slide) 0;
  }

  .sk-drawer[data-side="block-end"] {
    translate: 0 100%;
  }

  .sk-drawer[open] {
    opacity: 1;
    translate: 0 0;
  }

  @starting-style {
    .sk-drawer[data-side="inline-start"][open],
    .sk-drawer[data-side="inline-end"][open] {
      opacity: 0;
      translate: var(--_slide) 0;
    }

    .sk-drawer[data-side="block-end"][open] {
      opacity: 0;
      translate: 0 100%;
    }
  }

  .sk-drawer::backdrop {
    opacity: 0;
    transition: opacity var(--sk-motion-fast) var(--sk-ease-out);
  }

  .sk-drawer[open]::backdrop {
    opacity: 1;
  }

  @starting-style {
    .sk-drawer[open]::backdrop {
      opacity: 0;
    }
  }

  /* display and overlay are discrete: without allow-discrete the panel is gone
     before it can slide back out. Exit only. */
  @supports (transition-behavior: allow-discrete) {
    .sk-drawer {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        translate var(--sk-motion-base) var(--sk-ease-out),
        display var(--sk-motion-base) allow-discrete,
        overlay var(--sk-motion-base) allow-discrete;
    }

    .sk-drawer::backdrop {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }
  }
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
<dialog>widely37799815.4379815.4
Invoker commandsnewly13513514426.213514426.2
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
@starting-stylenewly11711712917.511712917.5
transition-behaviornewly11711712917.411712917.4
:dir()widely1201204916.41204916.4

Usage

A <dialog> with data-side. Everything else is the same as dialog — Invoker Commands open and close it, and <dialog> supplies modality.

<button type="button" command="show-modal" commandfor="filters">Filters</button>

<dialog id="filters" class="sk-drawer" data-side="inline-end" aria-labelledby="filters-title">
  <header class="sk-drawer__header">
    <h2 id="filters-title" class="sk-drawer__title">Filters</h2>
    <button type="button" class="sk-drawer__close" command="close" commandfor="filters" aria-label="Close filters">
      <span aria-hidden="true">&times;</span>
    </button>
  </header>
  <div class="sk-drawer__body">…</div>
</dialog>

data-side takes inline-start, inline-end or block-end.

How it works

Placement is logical margins, not position-area. A top-layer dialog’s containing block is the viewport — measured in all three engines — so pinning it to an edge is just margin: 0 plus one side set back to auto. position-area would need an anchor element, and a drawer is anchored to the viewport, not to anything on the page.

The payoff is that inline-start and inline-end flip in a right-to-left document with no extra CSS. Verified: in RTL the inline-end drawer renders at the left edge in Chromium, WebKit and Gecko.

The slide needs one direction-aware rule. translate is physical, so an inline-side drawer has to know which way “out” is when the document is RTL. A :dir(rtl) rule flips the travel distance — no @supports guard, because :dir() is Baseline widely available. The block-end sheet needs none of this.

No swipe-to-dismiss

Deliberate, and the reasoning is in ADR 0006.

Short version: CSS cannot close a <dialog>. A scroll-snap swipe can move the panel out of view, but the dialog stays open — backdrop still painted, page behind still inert, focus still confined to a sheet the user believes they dismissed. That is a trap, not a degraded experience, and it fails hardest for keyboard and screen-reader users.

The sheet has a close button and Esc. Both actually close it.

Theming

Set any of these anywhere above the component — :root, a section wrapper, or one instance. drawer.css only ever reads them, so the nearest declaration wins (ADR 0011).

PropertyDefaultControls
--sk-drawer-bg--sk-color-surface-raisedPanel background
--sk-drawer-fg--sk-color-textPanel text
--sk-drawer-shadow--sk-shadow-lgPanel elevation
--sk-drawer-radius--sk-radius-lgRadius on the panel’s inner corners
--sk-drawer-inline-size22remWidth of a side drawer before the viewport clamp
--sk-drawer-sheet-max-block-size80%Tallest a data-side="block-end" sheet grows
--sk-drawer-divider-color--sk-color-borderRule under the header
--sk-drawer-close-fg--sk-color-text-mutedClose button glyph
--sk-drawer-close-bg-hover--sk-color-surfaceClose button background on hover
--sk-drawer-button-bg--sk-color-surfaceTrigger background
--sk-drawer-button-bg-hover--sk-color-surface-raisedTrigger background on hover
--sk-drawer-button-fg--sk-color-textTrigger text
--sk-drawer-button-border-color--sk-color-border-strongTrigger border
--sk-drawer-button-radius--sk-radius-mdTrigger corner radius
:root { --sk-drawer-inline-size: 28rem; }

Which edge the panel is pinned to, and the slide that follows from it, are set by data-side — not a knob. The travel distance is derived from the side, so there is nothing to keep in step by hand.

Keyboard contract

Identical to dialog — it is the same element.

KeyBehaviour
Enter / Space on the triggerOpens the drawer; focus moves inside
TabMoves through the drawer; the page behind is inert
EscCloses and returns focus to the trigger

Verified in all three engines: opens as :modal, focus lands inside, the background refuses focus, Esc closes and returns focus.

As with dialog, wrapping at the last control is engine-dependent (Chromium and WebKit cycle; Gecko held focus on the last control in automation). Focus never escapes in any engine.

Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.

Accessibility notes

  • The drawer needs an accessible namearia-labelledby on the dialog pointing at the title.
  • The close button needs a text label. &times; is decorative, so it is aria-hidden and the button carries aria-label="Close filters". A bare glyph with no label announces as “button”.
  • A drawer is still a modal. Everything behind it is inert while it is open. If the content is not worth blocking the page for, use a popover instead.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
<dialog>widely
Invoker CommandsnewlyThe trigger does nothing. Same floor as dialog; see docs/polyfills.md for the opt-in consumer shim.
@starting-stylenewlyNo slide in; the drawer appears in place.
transition-behavior: allow-discretenewlyNo slide out; the drawer disappears at once.
:dir()widelyBelow the floor (Chrome 120 / Firefox 49 / Safari 16.4) the slide would run from the wrong side in RTL documents. Placement stays correct either way — logical margins do that, not :dir().

The usable floor is Invoker Commands, as with dialog: below it the trigger is an inert button and no CSS rescues that.