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
Drawer body content goes here.
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
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
</script>
<eun-button id="open">Open drawer</eun-button>
<eun-drawer id="drawer" heading="Edit profile">
<p>Drawer body content goes here.</p>
</eun-drawer>
<script type="module">
document.querySelector("#open").addEventListener("click", () => {
document.querySelector("#drawer").show();
});
</script>
npm install @eunomia/elements
import { useRef } from "react";
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
function ProfileEditor() {
const drawerRef = useRef(null);
return (
<>
<eun-button onClick={() => drawerRef.current.show()}>
Open drawer
</eun-button>
<eun-drawer ref={drawerRef} heading="Edit profile">
<p>Drawer body content goes here.</p>
</eun-drawer>
</>
);
}
npm install @eunomia/elements
"use client";
import { useRef } from "react";
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
export function ProfileEditor() {
const drawerRef = useRef(null);
return (
<>
<eun-button onClick={() => drawerRef.current.show()}>
Open drawer
</eun-button>
<eun-drawer ref={drawerRef} heading="Edit profile">
<p>Drawer body content goes here.</p>
</eun-drawer>
</>
);
}
npm install @eunomia/elements
<script setup>
import { ref } from "vue";
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
const drawer = ref(null);
</script>
<template>
<eun-button @click="drawer.show()">Open drawer</eun-button>
<eun-drawer ref="drawer" heading="Edit profile">
<p>Drawer body content goes here.</p>
</eun-drawer>
</template>
npm install @eunomia/elements
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
ViewChild,
ElementRef,
} from "@angular/core";
import "@eunomia/elements/drawer.js";
import "@eunomia/elements/modal.js";
@Component({
selector: "app-profile-editor",
template: `
<eun-button (click)="drawer.nativeElement.show()">Open drawer</eun-button>
<eun-drawer #drawer heading="Edit profile">
<p>Drawer body content goes here.</p>
</eun-drawer>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ProfileEditorComponent {
@ViewChild("drawer") drawer!: ElementRef;
}
Alternatives
eun-sidebareun-modalGuidance
- Use
eun-drawerfor a focused, single task triggered by an action: editing a record, a detail view, a form - Open and close it declaratively with a
command/commandforinvoker (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 ownpopovertarget: see Invoker Commands API below - Set
dirtywhenever the drawer holds unsaved form input, so an accidental close doesn't silently discard it - Set
positionto 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-drawerfor primary navigation the user needs constant access to: that's whatsidebar 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
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label-close | string | "Close" | The accessible label of the close button |
| confirm-close-heading | string | 'Discard changes?' | The heading of the internal discard confirmation dialog |
| confirm-close-description | string | "You have unsaved changes. This action can't be undone." | The description of the internal discard confirmation dialog |
| confirm-close-cancel-label | string | 'Keep editing' | The cancel label of the internal discard confirmation dialog |
| confirm-close-confirm-label | string | 'Discard' | The confirm label of the internal discard confirmation dialog |
| position | 'start' | 'end' | 'end' | Which edge the drawer slides in from |
| heading | string | — | An optional heading, rendered as the header slot's fallback content |
| closeable | boolean | true | Whether the close button renders |
| open | boolean | false | Whether the drawer is open |
| close-on-backdrop | boolean | true | Whether clicking outside the drawer requests a close |
| dirty | boolean | false | Marks the drawer as having unsaved changes, requiring confirmation before it closes |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The drawer's scrollable body content |
| header | Optional header content, such as a title, breadcrumbs, or actions, fixed above the scrollable body |
| footer | Optional footer content, such as save or cancel actions, fixed below the scrollable body. Omitted entirely when nothing is slotted |
| confirm-close | Optional custom content for the internal discard confirmation dialog, rendered below its heading and description |
Events
| Name | Type | Description |
|---|---|---|
| euncancel | DialogCancelEvent | Fired 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 |
| eundirtyclose | DirtyCloseEvent | Fired 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
CSS custom properties
| Name | Description |
|---|---|
| --drawer-width | Sets the width of the drawer panel |
| --drawer-background | Sets the background color of the drawer panel |
| --drawer-color | Sets the text color of the drawer panel |
| --drawer-backdrop-color | Sets the color of the backdrop behind the drawer |
| --drawer-padding | Sets the padding of the header, body, and footer |
| --drawer-gap | Sets the gap inside the header and footer rows |
| --drawer-transition-duration | Sets the duration of the slide transition |
Basic drawer
Drawer body content goes here.
<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.
<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
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.
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.
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).
This will permanently delete Project Alpha and all its data.
<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:
- If
dirtyis set, dispatch a cancelableeundirtyclose. A listener canpreventDefault()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. - If
dirtyisn't set, or once discard is confirmed (through the internal dialog or a substituted one), dispatch a cancelableeuncancel. A listener canpreventDefault()it to keep the drawer open unconditionally, for any reason (e.g. pending async work); this follows the same contract aseun-modal's owneuncancel. - 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.
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).
<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.
<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.
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