Skip to content

Modal

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

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

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.

size picks a preset width: small (24rem), medium (32rem, the default), large (48rem) or full (the whole viewport, with no border radius). For any other width, set --surface-modal-size and leave size out. The modal never gets closer than --surface-modal-gutter to the viewport edges.

With size="full" on a page that sets viewport-fit=cover, content can sit under a notch or home indicator. Add the safe-area insets to the padding yourself, for example padding-block-end: max(1.5rem, env(safe-area-inset-bottom)).

Sizes

An alert or a confirmation interrupts the user and needs an answer. It is the same web component with role="alertdialog" on the dialog:

<surface-modal size="small">
<dialog
id="delete"
role="alertdialog"
aria-labelledby="delete-title"
aria-describedby="delete-text"
>
<h2 id="delete-title">Delete this file?</h2>
<p id="delete-text">This cannot be undone.</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
</surface-modal>
  • Leave out closedby="any": a stray click on the backdrop should not answer the question. Esc still closes it without setting returnValue.
  • Put autofocus on the least destructive button.
  • Read the answer from dialog.returnValue in its close event, then set it back to "". The browser never clears it, so without the reset, Esc after an earlier “Delete” reads as "delete" again.
  • An alert has a single button.
dialog.addEventListener("close", () => {
if (dialog.returnValue === "delete") deleteFile()
dialog.returnValue = ""
})
Confirm

The modal is as tall as its content, up to the viewport minus --surface-modal-gutter, and then its content scrolls inside. Keep a header or footer in view with position: sticky inside the dialog.

Long content

The whole web component in one file, with its styles and dependencies inlined. Save it as surface-modal.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-modal.js"></script>
surface-modal.js (16.2 kB)
surface-modal.js
/*! <surface-modal> 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/modal/styles.ts
/**
* Modal styles for the `<dialog>` directly inside `<surface-modal>`. 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.
*
* `size` on the tag picks a preset width: `small`, `medium` (default, also
* missing or empty), `large` or `full`. Long content scrolls inside.
*/
const modalStyles = `
@layer surface-elements {
surface-modal {
display: contents;
}
surface-modal > dialog {
--_size: var(--surface-modal-size, 32rem);
--_gutter: var(--surface-modal-gutter, 1rem);
--_duration: var(--surface-modal-duration, 0.2s);
--_easing: var(--surface-modal-easing, cubic-bezier(0.2, 0, 0, 1));
--_hidden-scale: 0.96;
box-sizing: border-box;
position: fixed;
inset: 0;
margin: auto;
padding: var(--surface-modal-padding, 1.5rem);
border: var(--surface-modal-border, none);
border-radius: var(--surface-modal-radius, 0.75rem);
background: var(--surface-modal-background, Canvas);
color: var(--surface-modal-color, CanvasText);
box-shadow: var(--surface-modal-shadow, 0 1rem 3rem rgb(0 0 0 / 0.25));
/* Centered, as tall as its content, scrolling inside past the viewport. */
inline-size: var(--_size);
max-inline-size: calc(100% - 2 * var(--_gutter));
block-size: fit-content;
max-block-size: calc(100% - 2 * var(--_gutter));
overflow: auto;
scale: var(--_hidden-scale);
opacity: 0;
transition-property: scale, 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-modal > dialog:focus:not(:focus-visible) {
outline: none;
}
surface-modal[size="small"] > dialog {
--_size: 24rem;
}
surface-modal[size="large"] > dialog {
--_size: 48rem;
}
surface-modal[size="full"] > dialog {
inline-size: 100%;
max-inline-size: none;
block-size: 100%;
max-block-size: none;
border-radius: 0;
}
/* Scroll containment is part of the scroll lock: with it, wheel and touch
over the modal never reach the page, so no-scroll-lock drops it. */
surface-modal:not([no-scroll-lock]) > dialog,
surface-modal: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-modal > dialog::backdrop {
background: var(--surface-modal-backdrop, rgb(0 0 0 / 0.5));
backdrop-filter: var(--surface-modal-backdrop-filter, none);
opacity: 0;
overflow: hidden;
transition-property: opacity, display, overlay;
transition-duration: var(--surface-modal-duration, 0.2s);
transition-timing-function: var(--surface-modal-easing, cubic-bezier(0.2, 0, 0, 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-modal > dialog[open]:where(:not([data-closing])) {
scale: 1;
opacity: 1;
}
surface-modal > dialog[open]:where(:not([data-closing]))::backdrop {
opacity: 1;
}
@starting-style {
surface-modal > dialog[open] {
scale: var(--_hidden-scale);
opacity: 0;
}
surface-modal > dialog[open]::backdrop {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
surface-modal > dialog {
--_hidden-scale: 1;
}
}
}
`;
let adopted = false;
/** Adds {@link modalStyles} to the document once. */
function adoptModalStyles(root = document) {
if (adopted) return;
adopted = true;
if ("adoptedStyleSheets" in root && "replaceSync" in CSSStyleSheet.prototype) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(modalStyles);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
return;
}
const style = root.createElement("style");
style.dataset["surfaceElements"] = "surface-modal";
style.textContent = modalStyles;
root.head.append(style);
}
//#endregion
//#region src/modal/modal.ts
/**
* Web component that turns its direct child `<dialog>` into a centered modal.
* The dialog stays the source of truth: open state lives in `dialog.open`.
* Adds the modal styles to the document, so no CSS import is needed. The
* styles key on the tag, so the markup works with `modal.css` and no JS too.
*
* @tag surface-modal
* @slot - The modal `<dialog>`, as the direct child.
* @attr {"small" | "medium" | "large" | "full"} size - Preset width: `24rem`, `32rem` (default), `48rem`, or the whole viewport.
* @attr {boolean} no-scroll-lock - Leave page scroll untouched while open.
* @cssstate open - The child dialog is open.
* @cssprop [--surface-modal-size=32rem] - Inline size when `size` is not set.
* @cssprop [--surface-modal-gutter=1rem] - Minimum space between the modal and the viewport edges. Long content scrolls inside the modal.
* @cssprop [--surface-modal-padding=1.5rem] - Padding of the panel.
* @cssprop [--surface-modal-background=Canvas] - Background of the panel.
* @cssprop [--surface-modal-color=CanvasText] - Text color of the panel.
* @cssprop [--surface-modal-border=none] - Border of the panel.
* @cssprop [--surface-modal-radius=0.75rem] - Border radius of the panel.
* @cssprop [--surface-modal-shadow=0 1rem 3rem rgb(0 0 0 / 0.25)] - Shadow of the panel.
* @cssprop [--surface-modal-backdrop=rgb(0 0 0 / 0.5)] - Backdrop background.
* @cssprop [--surface-modal-backdrop-filter=none] - Backdrop filter.
* @cssprop [--surface-modal-duration=0.2s] - Enter and exit transition duration.
* @cssprop [--surface-modal-easing=cubic-bezier(0.2, 0, 0, 1)] - Enter and exit easing.
*/
var SurfaceModal = class extends HTMLElement {
static tagName = "surface-modal";
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() {
adoptModalStyles();
this.#host.connect();
}
attributeChangedCallback() {
this.#host.syncScrollLock();
}
disconnectedCallback() {
this.#host.disconnect();
}
};
defineElement(SurfaceModal.tagName, SurfaceModal);
//#endregion
export { SurfaceModal };

Attributes

AttributeTypeDescription
no-scroll-lockbooleanLeave page scroll untouched while open.
size"small" | "medium" | "large" | "full"Preset width: 24rem, 32rem (default), 48rem, or the whole viewport.

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 modal <dialog>, as the direct child.

CSS states

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

CSS custom properties

PropertyDefaultDescription
--surface-modal-size32remInline size when size is not set.
--surface-modal-gutter1remMinimum space between the modal and the viewport edges. Long content scrolls inside the modal.
--surface-modal-padding1.5remPadding of the panel.
--surface-modal-backgroundCanvasBackground of the panel.
--surface-modal-colorCanvasTextText color of the panel.
--surface-modal-bordernoneBorder of the panel.
--surface-modal-radius0.75remBorder radius of the panel.
--surface-modal-shadow0 1rem 3rem rgb(0 0 0 / 0.25)Shadow of the panel.
--surface-modal-backdroprgb(0 0 0 / 0.5)Backdrop background.
--surface-modal-backdrop-filternoneBackdrop filter.
--surface-modal-duration0.2sEnter and exit transition duration.
--surface-modal-easingcubic-bezier(0.2, 0, 0, 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-modal > dialog[data-closing].

The same as above. Keep the <surface-modal> wrapper even without the JavaScript: the styles select surface-modal > 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/modal.css";

You get the layout, the sizes 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 modal closes without animating.
  • closedby="any" only works where it is native. Elsewhere a backdrop click does nothing.
  • Page scroll is not locked while the modal is open.
  • surface-modal:state(open) never matches. Style surface-modal > dialog[open] instead.

To get the first two and the scroll lock 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. For an alert dialog, also point aria-describedby at the message.
  • Give it a visible close button, or explicit answers for an alert dialog. 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 trap focus: Tab can reach the browser’s own interface, as the native dialog allows.
  • Under prefers-reduced-motion: reduce the modal fades without scaling.

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.