Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Drawer

A full-height side panel that slides in from the edge of the screen with a backdrop behind it, for editing or detail panels triggered by an action. When it holds unsaved changes, any attempt to close it opens an internal confirmation dialog instead, so those changes aren't lost by accident.

The header and footer stay fixed while the body scrolls independently.

Dependencies

eun-modal · if dirty is ever set eun-icon · if closeable eun-button · if closeable
Overview API Examples Accessibility
Open drawer

Drawer body content goes here.

Cancel Save

When to use

Reach for a drawer when an action needs a focused side panel that comes and goes, such as editing a record, showing details for a row someone clicked, or a quick form. If the panel should stay in place alongside the page's content the whole time someone is using it, not just for one task, such as primary navigation or a persistent filter panel, reach for sidebar instead, since that's a non-modal layout fixture, while a drawer is a modal overlay by design. If the panel should block the rest of the page until someone responds, such as confirming something or filling out a short form with no navigation away from it, modal is usually the better fit, since it centers on screen and always traps focus, while a drawer stays pinned to an edge. See Alternatives below for a side by side comparison.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
<eun-button id="open">Open drawer</eun-button>
<eun-drawer id="drawer" heading="Edit profile">
  <p>Drawer body content goes here.</p>
  <eun-button slot="footer" appearance="ghost">Cancel</eun-button>
  <eun-button slot="footer">Save</eun-button>
</eun-drawer>
document.querySelector("#open").addEventListener("click", () => {
  document.querySelector("#drawer").show();
});

Importing drawer.js registers <eun-drawer>. Import modal.js too if you ever set dirty, since the drawer's internal discard-confirmation dialog renders a real <eun-modal> the first time dirty is set.

Alternatives

You want .. Prefers A panel that stays in place alongside the page's content, such as primary navigation or a persistent filter panel eun-sidebar A centered overlay that blocks the rest of the page until someone responds, with no navigation away from it eun-modal

Guidance

  • Use eun-drawer for a focused, single task triggered by an action: editing a record, a detail view, a form
  • Open and close it declaratively with a command/commandfor invoker (eun-button, eun-fab) whenever the trigger doesn't need to run other logic first, preferred over a manual click handler or the Popover API's own popovertarget: see Invoker Commands API below
  • Set dirty whenever the drawer holds unsaved form input, so an accidental close doesn't silently discard it
  • Set position to match which edge of the viewport makes sense for the task: "end" (the default) for most editing/detail flows, "start" when it should echo a left-to-right reading order
  • Using eun-drawer for primary navigation the user needs constant access to: that's what sidebar is for
  • Nesting the drawer's own dirty-confirmation flow inside another already-open dialog's critical path without testing the stacking

Live testing

Properties

Drawer <eun-drawer>

Attributes

NameTypeDefaultDescription
aria-label-closestring"Close"The accessible label of the close button
confirm-close-headingstring'Discard changes?'The heading of the internal discard confirmation dialog
confirm-close-descriptionstring"You have unsaved changes. This action can't be undone."The description of the internal discard confirmation dialog
confirm-close-cancel-labelstring'Keep editing'The cancel label of the internal discard confirmation dialog
confirm-close-confirm-labelstring'Discard'The confirm label of the internal discard confirmation dialog
position'start' | 'end''end'Which edge the drawer slides in from
headingstringAn optional heading, rendered as the header slot's fallback content
closeablebooleantrueWhether the close button renders
openbooleanfalseWhether the drawer is open
close-on-backdropbooleantrueWhether clicking outside the drawer requests a close
dirtybooleanfalseMarks the drawer as having unsaved changes, requiring confirmation before it closes

Import the exact TypeScript type behind any property above, see Types.

Slots

NameDescription
(default)The drawer's scrollable body content
headerOptional header content, such as a title, breadcrumbs, or actions, fixed above the scrollable body
footerOptional footer content, such as save or cancel actions, fixed below the scrollable body. Omitted entirely when nothing is slotted
confirm-closeOptional custom content for the internal discard confirmation dialog, rendered below its heading and description

Events

NameTypeDescription
euncancelDialogCancelEventFired right before the drawer actually closes, whether from the close button, Escape, an outside click, a request to close, or a confirmed discard. Cancelable to keep it open. Not fired for a close attempt that only opens the discard confirmation dialog
eundirtycloseDirtyCloseEventFired right before the internal discard confirmation dialog would open, on any close attempt while dirty is set. Cancelable, so a listener can show its own confirmation instead

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--drawer-widthSets the width of the drawer panel
--drawer-backgroundSets the background color of the drawer panel
--drawer-colorSets the text color of the drawer panel
--drawer-backdrop-colorSets the color of the backdrop behind the drawer
--drawer-paddingSets the padding of the header, body, and footer
--drawer-gapSets the gap inside the header and footer rows
--drawer-transition-durationSets the duration of the slide transition

Basic drawer

Open

Drawer body content goes here.

Cancel Save
<eun-button id="open">Open</eun-button>
<eun-drawer id="drawer" heading="Edit profile">
  <p>Drawer body content goes here.</p>
  <eun-button slot="footer" appearance="ghost">Cancel</eun-button>
  <eun-button slot="footer">Save</eun-button>
</eun-drawer>
document.querySelector("#open").addEventListener("click", () => {
  document.querySelector("#drawer").show();
});

Invoker Commands API

Prefer this over the click-handler wiring above whenever the trigger doesn't need to run anything else first: eun-drawer listens for the Invoker Commands API's command event on itself, so any invoker, eun-button, eun-fab, or a native <button>, can open and close it declaratively, with no <script> at all. It also replaces the Popover API's own popovertarget, which only ever covers popover-attributed targets, not a dialog like this one.

Recent notifications appear here.

Open
<eun-drawer id="notifications-drawer" heading="Notifications">
  <p>Recent notifications appear here.</p>
</eun-drawer>
<eun-button command="show-modal" commandfor="notifications-drawer">Open</eun-button>

show-modal calls show(), close calls hide(), and request-close calls requestClose(), so it respects dirty exactly like a call from your own code would. See the Invoker Commands API guide for how the attributes work, custom commands, and browser support.

Position

position picks which edge the drawer is anchored to, and which direction it slides in from. start (left, in LTR) slides in left-to-right, while end (right) slides in right-to-left. Either way, the surface starts fully off-screen past its own edge and slides to its resting position, not a subtle nudge.

Open from start (left) Open from end (right)

Slides in from the inline-start edge, left to right.

Slides in from the inline-end edge, right to left.

<eun-drawer position="start" heading="From the start edge"> ... </eun-drawer>
<eun-drawer position="end" heading="From the end edge"> ... </eun-drawer>

Custom header content

header is a real slot, not just a title string: put breadcrumbs, tabs, or actions in it.

Open
Project Alpha

Body content.

<eun-drawer>
  <div slot="header" style="display: flex; align-items: center; gap: 8px;">
    <eun-icon name="folder" size="18"></eun-icon>
    <strong>Project Alpha</strong>
  </div>
  <p>Body content.</p>
</eun-drawer>

Custom footer

footer is a real slot too, not a fixed pair of buttons: any content works, and the row is omitted entirely when nothing is slotted into it (see the Basic example above, which has no footer at all).

Open

This will permanently delete Project Alpha and all its data.

This can't be undone.
Cancel Delete
<eun-drawer heading="Delete project">
  <p>
    This will permanently delete <strong>Project Alpha</strong> and all its
    data.
  </p>
  <div
    slot="footer"
    style="display: flex; align-items: center; justify-content: space-between; width: 100%;"
  >
    <span>This can't be undone.</span>
    <div style="display: flex; gap: 8px;">
      <eun-button appearance="ghost">Cancel</eun-button>
      <eun-button variant="critical">Delete</eun-button>
    </div>
  </div>
</eun-drawer>

Unsaved changes

dirty gates every close attempt, whether it's the close button, Escape, an outside click, or a direct requestClose() call, behind an internal "Discard changes?" eun-modal, instead of eun-drawer reinventing its own confirmation UI. Under the hood, requestClose() does exactly this, in order:

  1. If dirty is set, dispatch a cancelable eundirtyclose. A listener can preventDefault() it to take over the confirmation UI entirely instead of the internal dialog (see Custom confirmation UI below). If not prevented, the internal confirmation dialog opens instead, and nothing closes yet.
  2. If dirty isn't set, or once discard is confirmed (through the internal dialog or a substituted one), dispatch a cancelable euncancel. A listener can preventDefault() it to keep the drawer open unconditionally, for any reason (e.g. pending async work); this follows the same contract as eun-modal's own euncancel.
  3. If not prevented, close.

Confirming discard clears dirty and closes the drawer, while canceling the confirmation leaves the drawer open, still dirty. Try it below: type in the field, then close the drawer.

Open Cancel Save

This minimal reproducible setup is the entire wiring needed, nothing else:

<eun-drawer id="drawer" heading="Edit note">
  <eun-input id="note" label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost" id="cancel">Cancel</eun-button>
  <eun-button slot="footer" id="save">Save</eun-button>
</eun-drawer>
const drawer = document.querySelector("#drawer");
const note = document.querySelector("#note");

// Any input marks the drawer dirty ; a real app would likely track this
// more precisely (e.g. compare against the loaded value), this just
// demonstrates the wiring.
note.addEventListener("eunchange", () => {
  drawer.dirty = true;
});

// Cancel always goes through requestClose() — dirty decides whether that
// closes immediately or opens the confirmation first.
document.querySelector("#cancel").addEventListener("click", () => {
  drawer.requestClose();
});

// Save bypasses the gate entirely : there's nothing to discard once saved.
document.querySelector("#save").addEventListener("click", () => {
  drawer.dirty = false;
  drawer.hide();
});

The confirmation dialog's own text is customizable via confirm-close-heading, confirm-close-description, confirm-close-cancel-label, and confirm-close-confirm-label. See the API tab for their defaults. It's a real eun-modal internally, so it also picks up a softly blurred backdrop (--modal-backdrop-blur, eun-modal's own; see its Examples tab) by default, to set an important decision apart from the page behind it.

Custom confirmation content

For anything beyond a heading/description pair, fill the confirm-close slot: it renders inside the internal eun-modal, below its heading and description, exactly like slotting content into a eun-modal directly (see its own "Arbitrary content" example).

Open Cancel
Drafts are kept for 30 days even if discarded.
<eun-drawer heading="Edit note" dirty>
  <eun-input label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost">Cancel</eun-button>
  <div slot="confirm-close">
    <eun-icon name="info" size="16"></eun-icon>
    Drafts are kept for 30 days even if discarded.
  </div>
</eun-drawer>

Custom confirmation UI

For anything beyond styling the internal dialog, skip it entirely: preventDefault() the cancelable eundirtyclose to take over with whatever confirmation UI is needed, such as window.confirm(), a custom element, or an async flow, then call confirmDiscard() once the user has agreed to discard their changes. The internal eun-modal is never opened in that case.

Open Cancel Save
<eun-drawer id="drawer" heading="Edit note">
  <eun-input id="note" label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost" id="cancel">Cancel</eun-button>
  <eun-button slot="footer" id="save">Save</eun-button>
</eun-drawer>
const drawer = document.querySelector("#drawer");
const note = document.querySelector("#note");

note.addEventListener("eunchange", () => {
  drawer.dirty = true;
});

document.querySelector("#cancel").addEventListener("click", () => {
  drawer.requestClose();
});

document.querySelector("#save").addEventListener("click", () => {
  drawer.dirty = false;
  drawer.hide();
});

// Take over the confirmation UI entirely : skip the internal eun-modal
// and ask through window.confirm() instead.
drawer.addEventListener("eundirtyclose", (event) => {
  event.preventDefault();
  if (window.confirm("Discard your unsaved changes?")) {
    drawer.confirmDiscard();
  }
});

Custom

Override the --drawer-* CSS variables, listed in full in the API tab. Pair --drawer-background with --drawer-color whenever the background is dark or saturated enough to need it: the default text color doesn't adapt to it automatically.

Open

Custom styled drawer.

<eun-drawer class="custom-drawer" heading="Custom">...</eun-drawer>
.custom-drawer {
  --drawer-width: 320px;
  --drawer-background: #4c1d95;
  --drawer-color: #fff;
  --drawer-backdrop-color: rgba(76, 29, 149, 0.5);
}

Keyboard interactions

When open:

Key Action
Escape Requests a close (subject to the dirty gate)
Tab / Shift+Tab Cycles focus through the drawer's focusable content

Unlike <dialog>'s showModal(), the native Popover API doesn't natively trap focus inside the surface: Tab/Shift+Tab can currently reach content outside the drawer if nothing else constrains it. Same limitation eun-modal documents on its own Accessibility tab.

Focus management

On open, focus moves to the first [autofocus] element in the slotted content, or the drawer panel itself otherwise. On close, however it closes, focus returns to whatever had it right before the drawer opened, per the WAI-ARIA Dialog pattern (captured the moment the popover actually opens, not when show() is called).

Aria attributes

role="dialog", aria-modal="true", aria-labelledby wired to heading when set. If heading isn't set, a plain aria-label attribute set directly on eun-drawer is read and forwarded instead, the same pattern eun-modal uses for its own flush kind="search" shell.

Reduced motion

The entrance/exit transition (slide + fade for the drawer, fade for the backdrop) collapses to near-instant under prefers-reduced-motion: reduce, rather than being skipped outright: display/overlay still need an active transition to hook into (via allow-discrete) for the popover to delay top-layer removal correctly, so the duration is reduced to effectively zero instead of removing the transition declaration.

Reference links

WAI-ARIA Dialog (Modal) Pattern
HTML Specification: Popover API
WCAG 2.1: Animation from Interactions