Skip to content

Drawer

<surface-drawer> turns the native <dialog> inside it into a drawer. The dialog stays the source of truth: open state lives in dialog.open, and nothing mirrors it.

import "surface-elements/drawer"
<button commandfor="cart" command="show-modal">Cart</button>
<surface-drawer>
<dialog id="cart" closedby="any" aria-labelledby="cart-title">
<h2 id="cart-title">Cart</h2>
<button commandfor="cart" command="close">Close</button>
</dialog>
</surface-drawer>

The import defines the web component, which adds the styles to the document, so there is no CSS to import. It enhances its direct child dialog, rebinds when you swap it, and cleans up when it leaves the page.

  • command="show-modal" opens it as a modal, in the top layer, with focus moved inside.
  • closedby="any" closes it on Esc and on a click on the backdrop.
  • <form method="dialog"> closes it and sets returnValue from the submit button.

edge sets the edge: end (the default), start, top or bottom. Inline edges follow the writing direction, so end is on the left in RTL.

Edges

end

start

top

bottom

Driven from JS

Driven from JS

The methods call the same functions you can use on your own.

The whole web component in one file, with its styles and dependencies inlined. Save it as surface-drawer.js in your project and load it as a module. Nothing to install, and no updates unless you copy it again: see Use without installing.

<script type="module" src="/surface-drawer.js"></script>
surface-drawer.js (16.9 kB)
surface-drawer.js
/*! <surface-drawer> from surface-elements, https://github.com/anuarhdz/surface-elements
* MIT License, Anuar Reyes <anuar@hey.com>
*
* Copy file: the whole web component in one module, with nothing to
* install. Load it with <script type="module" src="…"></script>.
* Generated by `pnpm build` from src/; do not edit it in the repo.
*/
//#region src/scroll-lock/scroll-lock.ts
const STATE = Symbol.for("surface-elements.scroll-lock");
const state = globalThis[STATE] ??= {
locks: 0,
unlock: void 0
};
/**
* Blocks page scroll by setting `overflow: hidden` on `<html>`. `<body>` is left
* alone: hiding its overflow too would make it a scroll container and unstick
* every `position: sticky` element on a scrolled page.
* Ref-counted: the page unlocks when every caller has released its lock,
* across every copy of this module on the page.
* Returns an idempotent release function.
*/
function lockScroll() {
if (state.locks++ === 0) state.unlock = applyLock();
let released = false;
return () => {
if (released) return;
released = true;
if (--state.locks === 0) {
state.unlock?.();
state.unlock = void 0;
}
};
}
function applyLock() {
const html = document.documentElement;
const previous = {
htmlOverflow: html.style.getPropertyValue("overflow"),
gutter: html.style.getPropertyValue("scrollbar-gutter")
};
if (window.innerWidth - html.clientWidth > 0) html.style.setProperty("scrollbar-gutter", "stable");
html.style.setProperty("overflow", "hidden");
return () => {
restore(html, "overflow", previous.htmlOverflow);
restore(html, "scrollbar-gutter", previous.gutter);
};
}
function restore(el, property, value) {
if (value) el.style.setProperty(property, value);
else el.style.removeProperty(property);
}
//#endregion
//#region src/dialog/dialog.ts
const noop = () => {};
/** Attribute set while an exit transition plays in browsers without `overlay`. */
const CLOSING_ATTRIBUTE = "data-closing";
/**
* Applies every dialog enhancement this library provides: light-dismiss
* fallback, animated close requests and scroll lock. Returns a cleanup.
*/
function enhanceDialog(dialog, options = {}) {
const cleanups = [lightDismiss(dialog), routeCloseRequests(dialog)];
if (options.lockScroll ?? true) cleanups.push(lockScrollWhileOpen(dialog));
return () => {
for (const cleanup of cleanups) cleanup();
};
}
/**
* Fallback for `closedby="any"` where the browser lacks it: closes the
* dialog when a click starts and ends on the backdrop. No-op where native.
*/
function lightDismiss(dialog, close = () => closeAnimated(dialog)) {
if (supportsClosedBy()) return noop;
const controller = new AbortController();
const { signal } = controller;
let pressedBackdrop = false;
const isBackdrop = (event) => {
if (event.target !== dialog) return false;
const rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
};
dialog.addEventListener("pointerdown", (event) => {
pressedBackdrop = isBackdrop(event);
}, { signal });
dialog.addEventListener("click", (event) => {
const shouldClose = pressedBackdrop && isBackdrop(event);
pressedBackdrop = false;
if (!shouldClose) return;
if (dialog.getAttribute("closedby") !== "any" || !isModal(dialog)) return;
close();
}, { signal });
return () => controller.abort();
}
/**
* Where exit transitions out of the top layer are unsupported, routes Esc,
* light dismiss, `command="close"` and `<form method="dialog">` through
* {@link closeAnimated} so they animate too. No-op where native.
*/
function routeCloseRequests(dialog) {
if (supportsTopLayerExit()) return noop;
const controller = new AbortController();
const { signal } = controller;
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
closeAnimated(dialog);
}, { signal });
dialog.addEventListener("command", (event) => {
const command = "command" in event ? event.command : void 0;
if (command === "close") {
event.preventDefault();
closeAnimated(dialog);
} else if (command === "show-modal") dialog.removeAttribute(CLOSING_ATTRIBUTE);
}, { signal });
dialog.addEventListener("submit", (event) => {
if (event.defaultPrevented) return;
const form = event.target;
const submitter = event.submitter;
if (form.closest("dialog") !== dialog) return;
if ((submitter?.getAttribute("formmethod") ?? form.getAttribute("method"))?.toLowerCase() !== "dialog") return;
event.preventDefault();
closeAnimated(dialog, submitter?.getAttribute("value") ?? void 0);
}, { signal });
return () => controller.abort();
}
const EXIT_TIMEOUT = 2e3;
const closing = /* @__PURE__ */ new WeakMap();
/**
* Closes the dialog after its exit transition. In browsers that transition
* `display` and `overlay` natively this is a plain `close()`; elsewhere it
* sets `data-closing`, waits for the animations and then closes. A call
* during an exit in progress returns that exit's promise.
*/
function closeAnimated(dialog, returnValue) {
const pending = closing.get(dialog);
if (pending && dialog.hasAttribute("data-closing")) return pending;
if (!dialog.open) return Promise.resolve();
const exit = playExit(dialog, returnValue).finally(() => {
if (closing.get(dialog) === exit) closing.delete(dialog);
});
closing.set(dialog, exit);
return exit;
}
async function playExit(dialog, returnValue) {
if (!supportsTopLayerExit()) {
dialog.setAttribute(CLOSING_ATTRIBUTE, "");
const animations = dialog.getAnimations?.({ subtree: true }) ?? [];
if (animations.length > 0) await Promise.race([Promise.allSettled(animations.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, EXIT_TIMEOUT))]);
if (!dialog.hasAttribute("data-closing")) return;
dialog.removeAttribute(CLOSING_ATTRIBUTE);
}
dialog.close(returnValue);
}
/** Opens the dialog as a modal, cancelling an exit transition in progress. */
function showModal(dialog) {
closing.delete(dialog);
dialog.removeAttribute(CLOSING_ATTRIBUTE);
if (!dialog.open) dialog.showModal();
}
/** Holds a {@link lockScroll} lock for as long as the dialog is modal. */
function lockScrollWhileOpen(dialog) {
let release;
const sync = () => {
const open = dialog.open && isModal(dialog);
if (open && !release) release = lockScroll();
else if (!open && release) {
release();
release = void 0;
}
};
const observer = new MutationObserver(sync);
observer.observe(dialog, {
attributes: true,
attributeFilter: ["open"]
});
sync();
return () => {
observer.disconnect();
release?.();
release = void 0;
};
}
/** Whether the browser supports `closedby` on dialogs. */
function supportsClosedBy() {
return "closedBy" in HTMLDialogElement.prototype;
}
let topLayerExit;
/** Whether exit transitions out of the top layer work without help. */
function supportsTopLayerExit() {
topLayerExit ??= (globalThis.CSS?.supports?.("overlay", "auto") ?? false) && canTransitionDisplay();
return topLayerExit;
}
function canTransitionDisplay() {
if (!globalThis.CSS?.supports?.("transition-behavior", "allow-discrete")) return false;
const probe = document.createElement("div");
probe.style.cssText = "transition: display 1s allow-discrete !important; display: block;";
document.documentElement.append(probe);
getComputedStyle(probe).display;
probe.style.display = "none";
const supported = getComputedStyle(probe).display === "block";
probe.remove();
return supported;
}
function isModal(dialog) {
try {
return dialog.matches(":modal");
} catch {
return dialog.open;
}
}
//#endregion
//#region src/utils/define.ts
function defineElement(tagName, constructor) {
if (customElements.get(tagName) === void 0) customElements.define(tagName, constructor);
}
//#endregion
//#region src/utils/dialog-host.ts
/** The direct child `<dialog>` of a web component, if any. */
function childDialog(host) {
return host.querySelector(":scope > dialog");
}
/**
* Shared wiring for web components that wrap a direct child `<dialog>`:
* enhances it, rebinds when it is swapped, holds the scroll lock unless the
* host has `no-scroll-lock`, and mirrors `dialog.open` as `:state(open)`.
*/
function dialogHost(host, internals) {
let current = null;
let cleanup;
let releaseScrollLock;
let observer;
const syncScrollLock = () => {
const wanted = current !== null && !host.hasAttribute("no-scroll-lock");
if (current && wanted && !releaseScrollLock) releaseScrollLock = lockScrollWhileOpen(current);
else if (!wanted && releaseScrollLock) {
releaseScrollLock();
releaseScrollLock = void 0;
}
};
const unbind = () => {
releaseScrollLock?.();
releaseScrollLock = void 0;
cleanup?.();
cleanup = void 0;
current = null;
};
const sync = () => {
const dialog = childDialog(host);
if (dialog !== current) {
unbind();
if (dialog) {
const options = { lockScroll: false };
current = dialog;
cleanup = enhanceDialog(dialog, options);
syncScrollLock();
}
}
const states = internals?.states;
if (!states) return;
if (dialog?.open) states.add("open");
else states.delete("open");
};
return {
connect() {
observer ??= new MutationObserver(sync);
observer.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["open"]
});
sync();
},
disconnect() {
observer?.disconnect();
unbind();
},
syncScrollLock
};
}
//#endregion
//#region src/drawer/styles.ts
/**
* Drawer styles for the `<dialog>` directly inside `<surface-drawer>`. Light
* DOM, wrapped in the `surface-elements` cascade layer so any unlayered
* consumer rule wins. The selectors match before the web component is defined,
* so the styles also work without its JavaScript.
*
* `edge` on the tag takes the edge the drawer is attached to:
* `end` (default, also missing or empty), `start`, `top` or `bottom`.
*/
const drawerStyles = `
@layer surface-elements {
surface-drawer {
display: contents;
}
surface-drawer > dialog {
--_size: var(--surface-drawer-size, 28rem);
--_max-size: var(--surface-drawer-max-size, calc(100% - 3rem));
--_duration: var(--surface-drawer-duration, 0.3s);
--_easing: var(--surface-drawer-easing, cubic-bezier(0.38, 0.005, 0.215, 1));
/* Hidden offset: --_x/--_y per edge, --_dir flips inline edges in RTL,
--_motion drops the slide under reduced motion. */
--_dir: 1;
--_motion: 1;
--_x: calc(100% * var(--_dir));
--_y: 0%;
--_hidden: calc(var(--_x) * var(--_motion)) calc(var(--_y) * var(--_motion));
--_hidden-opacity: 1;
box-sizing: border-box;
position: fixed;
margin: 0;
padding: var(--surface-drawer-padding, 1rem);
border: var(--surface-drawer-border, none);
border-radius: var(--surface-drawer-radius, 0);
background: var(--surface-drawer-background, Canvas);
color: var(--surface-drawer-color, CanvasText);
box-shadow: var(--surface-drawer-shadow, 0 0 2rem rgb(0 0 0 / 0.2));
/* Attached to the inline-end edge, full height. */
inset-block: 0;
inset-inline: auto 0;
block-size: auto;
max-block-size: none;
inline-size: var(--_size);
max-inline-size: var(--_max-size);
overflow: auto;
translate: var(--_hidden);
opacity: var(--_hidden-opacity);
transition-property: translate, opacity, display, overlay;
transition-duration: var(--_duration);
transition-timing-function: var(--_easing);
transition-behavior: allow-discrete;
}
/* The dialog itself takes focus when it has nothing focusable: keep the
ring for keyboard users only. */
surface-drawer > dialog:focus:not(:focus-visible) {
outline: none;
}
surface-drawer > dialog:dir(rtl) {
--_dir: -1;
}
surface-drawer[edge="start"] > dialog {
inset-inline: 0 auto;
--_x: calc(-100% * var(--_dir));
}
surface-drawer[edge="top"] > dialog,
surface-drawer[edge="bottom"] > dialog {
inset-inline: 0;
inline-size: auto;
max-inline-size: none;
block-size: var(--surface-drawer-size, auto);
max-block-size: var(--_max-size);
--_x: 0%;
}
surface-drawer[edge="top"] > dialog {
inset-block: 0 auto;
--_y: -100%;
}
surface-drawer[edge="bottom"] > dialog {
inset-block: auto 0;
--_y: 100%;
}
/* Scroll containment is part of the scroll lock: with it, wheel and touch
over the drawer never reach the page, so no-scroll-lock drops it. */
surface-drawer:not([no-scroll-lock]) > dialog,
surface-drawer:not([no-scroll-lock]) > dialog::backdrop {
overscroll-behavior: contain;
}
/* ::backdrop reads the public properties directly: older engines do not
let it inherit from the dialog. */
surface-drawer > dialog::backdrop {
background: var(--surface-drawer-backdrop, rgb(0 0 0 / 0.5));
backdrop-filter: var(--surface-drawer-backdrop-filter, none);
opacity: 0;
overflow: hidden;
transition-property: opacity, display, overlay;
transition-duration: var(--surface-drawer-duration, 0.3s);
transition-timing-function: var(--surface-drawer-easing, cubic-bezier(0.38, 0.005, 0.215, 1));
transition-behavior: allow-discrete;
}
/* Open state. :where() keeps the specificity equal to the
@starting-style rules below so those still apply on entry. */
surface-drawer > dialog[open]:where(:not([data-closing])) {
translate: 0 0;
opacity: 1;
}
surface-drawer > dialog[open]:where(:not([data-closing]))::backdrop {
opacity: 1;
}
@starting-style {
surface-drawer > dialog[open] {
translate: var(--_hidden);
opacity: var(--_hidden-opacity);
}
surface-drawer > dialog[open]::backdrop {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
surface-drawer > dialog {
--_motion: 0;
--_hidden-opacity: 0;
--_duration: 0.15s;
}
}
}
`;
let adopted = false;
/** Adds {@link drawerStyles} to the document once. */
function adoptDrawerStyles(root = document) {
if (adopted) return;
adopted = true;
if ("adoptedStyleSheets" in root && "replaceSync" in CSSStyleSheet.prototype) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(drawerStyles);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
return;
}
const style = root.createElement("style");
style.dataset["surfaceElements"] = "surface-drawer";
style.textContent = drawerStyles;
root.head.append(style);
}
//#endregion
//#region src/drawer/drawer.ts
/**
* Web component that turns its direct child `<dialog>` into a drawer.
* The dialog stays the source of truth: open state lives in `dialog.open`.
* Adds the drawer styles to the document, so no CSS import is needed. The
* styles key on the tag, so the markup works with `drawer.css` and no JS too.
*
* @tag surface-drawer
* @slot - The drawer `<dialog>`, as the direct child.
* @attr {"end" | "start" | "top" | "bottom"} edge - Edge the drawer is attached to. Defaults to `end`. Inline edges follow the writing direction.
* @attr {boolean} no-scroll-lock - Leave page scroll untouched while open.
* @cssstate open - The child dialog is open.
* @cssprop [--surface-drawer-size=28rem] - Inline size for `start`/`end`, block size for `top`/`bottom` (`auto`).
* @cssprop [--surface-drawer-max-size=calc(100% - 3rem)] - Upper bound for the size.
* @cssprop [--surface-drawer-padding=1rem] - Padding of the dialog.
* @cssprop [--surface-drawer-background=Canvas] - Background of the dialog.
* @cssprop [--surface-drawer-color=CanvasText] - Text color of the dialog.
* @cssprop [--surface-drawer-border=none] - Border of the dialog.
* @cssprop [--surface-drawer-radius=0] - Border radius of the dialog.
* @cssprop [--surface-drawer-shadow=0 0 2rem rgb(0 0 0 / 0.2)] - Shadow of the dialog.
* @cssprop [--surface-drawer-backdrop=rgb(0 0 0 / 0.5)] - Backdrop background.
* @cssprop [--surface-drawer-backdrop-filter=none] - Backdrop filter.
* @cssprop [--surface-drawer-duration=0.3s] - Enter and exit transition duration.
* @cssprop [--surface-drawer-easing=cubic-bezier(0.38, 0.005, 0.215, 1)] - Enter and exit easing.
*/
var SurfaceDrawer = class extends HTMLElement {
static tagName = "surface-drawer";
static observedAttributes = ["no-scroll-lock"];
#host = dialogHost(this, this.attachInternals?.());
/** The direct child `<dialog>`, if any. */
get dialog() {
return childDialog(this);
}
/** Mirrors `dialog.open`. */
get open() {
return this.dialog?.open ?? false;
}
/** Opens the dialog as a modal, cancelling an exit in progress. */
show() {
const dialog = this.dialog;
if (dialog) showModal(dialog);
}
/** Closes after the exit transition. Resolves when closed. */
close(returnValue) {
const dialog = this.dialog;
return dialog ? closeAnimated(dialog, returnValue) : Promise.resolve();
}
/** Calls `close()` when open, `show()` when closed or closing. */
toggle() {
const dialog = this.dialog;
if (dialog?.open && !dialog.hasAttribute("data-closing")) this.close();
else this.show();
}
connectedCallback() {
adoptDrawerStyles();
this.#host.connect();
}
attributeChangedCallback() {
this.#host.syncScrollLock();
}
disconnectedCallback() {
this.#host.disconnect();
}
};
defineElement(SurfaceDrawer.tagName, SurfaceDrawer);
//#endregion
export { SurfaceDrawer };

Attributes

AttributeTypeDescription
no-scroll-lockbooleanLeave page scroll untouched while open.
edge"end" | "start" | "top" | "bottom"Edge the drawer is attached to. Defaults to end. Inline edges follow the writing direction.

Properties

PropertyTypeDescription
dialogHTMLDialogElement | null, read-onlyThe direct child <dialog>, if any.
openboolean, read-onlyMirrors dialog.open.

Methods

MethodReturnsDescription
show()voidOpens the dialog as a modal, cancelling an exit in progress.
close(returnValue?)Promise<void>Closes after the exit transition. Resolves when closed.
toggle()voidCalls close() when open, show() when closed or closing.

Slots

SlotDescription
(default)The drawer <dialog>, as the direct child.

CSS states

SelectorDescription
surface-drawer:state(open)The child dialog is open.

CSS custom properties

PropertyDefaultDescription
--surface-drawer-size28remInline size for start/end, block size for top/bottom (auto).
--surface-drawer-max-sizecalc(100% - 3rem)Upper bound for the size.
--surface-drawer-padding1remPadding of the dialog.
--surface-drawer-backgroundCanvasBackground of the dialog.
--surface-drawer-colorCanvasTextText color of the dialog.
--surface-drawer-bordernoneBorder of the dialog.
--surface-drawer-radius0Border radius of the dialog.
--surface-drawer-shadow0 0 2rem rgb(0 0 0 / 0.2)Shadow of the dialog.
--surface-drawer-backdroprgb(0 0 0 / 0.5)Backdrop background.
--surface-drawer-backdrop-filternoneBackdrop filter.
--surface-drawer-duration0.3sEnter and exit transition duration.
--surface-drawer-easingcubic-bezier(0.38, 0.005, 0.215, 1)Enter and exit easing.

The web component is display: contents, so it adds no box to the layout. While a fallback exit animation plays, its dialog carries data-closing: select it with surface-drawer > dialog[data-closing].

Scroll lock opt-out

no-scroll-lock

The page behind can still scroll.

The same as above. Keep the <surface-drawer> wrapper even without the JavaScript: the styles select surface-drawer > dialog, and an undefined tag still renders its children.

Load the stylesheet instead of the web component. No JavaScript from this library runs.

@import "surface-elements/drawer.css";

You get the layout, the edges and the enter and exit transitions where the browser supports them natively. The rules live in @layer surface-elements, so any unlayered rule of yours wins.

The web component calls enhanceDialog() on its dialog and adds the styles. Without it:

  • The exit transition only plays where the browser animates out of the top layer. Elsewhere the drawer closes without animating.
  • closedby="any" only works where it is native. Elsewhere a backdrop click does nothing.
  • Page scroll is not locked while the drawer is open.
  • surface-drawer:state(open) never matches. Style surface-drawer > dialog[open] instead.

To get the first three without the web component, call enhanceDialog(dialog) yourself and keep the CSS import. Do not do both on the same dialog.

  • Label the dialog. Point aria-labelledby at its heading, or set aria-label.
  • Give it a visible close button. Esc and the backdrop are not discoverable for everyone.
  • Focus, the inert page behind and Esc come from the native modal dialog. The library does not reimplement them.
  • Under prefers-reduced-motion: reduce the drawer fades instead of sliding.

Why the dialog lives in light DOM: ADR 0001.

See Browser support for what is native, what falls back and which polyfills you may load.