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.
Install and use
Section titled “Install and use”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 setsreturnValuefrom 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)).
Alert and confirm
Section titled “Alert and confirm”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 settingreturnValue. - Put
autofocuson the least destructive button. - Read the answer from
dialog.returnValuein itscloseevent, 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 = ""})Long content
Section titled “Long content”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.
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> 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.tsconst 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.tsconst 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.tsfunction 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);//#endregionexport { SurfaceModal };Attributes
| Attribute | Type | Description |
|---|---|---|
no-scroll-lock | boolean | Leave page scroll untouched while open. |
size | "small" | "medium" | "large" | "full" | Preset width: 24rem, 32rem (default), 48rem, or the whole viewport. |
Properties
| Property | Type | Description |
|---|---|---|
dialog | HTMLDialogElement | null, read-only | The direct child <dialog>, if any. |
open | boolean, read-only | Mirrors dialog.open. |
Methods
| Method | Returns | Description |
|---|---|---|
show() | void | Opens the dialog as a modal, cancelling an exit in progress. |
close(returnValue?) | Promise<void> | Closes after the exit transition. Resolves when closed. |
toggle() | void | Calls close() when open, show() when closed or closing. |
Slots
| Slot | Description |
|---|---|
| (default) | The modal <dialog>, as the direct child. |
CSS states
| Selector | Description |
|---|---|
surface-modal:state(open) | The child dialog is open. |
CSS custom properties
| Property | Default | Description |
|---|---|---|
--surface-modal-size | 32rem | Inline size when size is not set. |
--surface-modal-gutter | 1rem | Minimum space between the modal and the viewport edges. Long content scrolls inside the modal. |
--surface-modal-padding | 1.5rem | Padding of the panel. |
--surface-modal-background | Canvas | Background of the panel. |
--surface-modal-color | CanvasText | Text color of the panel. |
--surface-modal-border | none | Border of the panel. |
--surface-modal-radius | 0.75rem | Border radius of the panel. |
--surface-modal-shadow | 0 1rem 3rem rgb(0 0 0 / 0.25) | Shadow of the panel. |
--surface-modal-backdrop | rgb(0 0 0 / 0.5) | Backdrop background. |
--surface-modal-backdrop-filter | none | Backdrop filter. |
--surface-modal-duration | 0.2s | Enter and exit transition duration. |
--surface-modal-easing | cubic-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].
If you implement it by hand
Section titled “If you implement it by hand”Markup
Section titled “Markup”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.
What the JavaScript adds
Section titled “What the JavaScript adds”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. Stylesurface-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.
Accessibility
Section titled “Accessibility”- Label the dialog. Point
aria-labelledbyat its heading, or setaria-label. For an alert dialog, also pointaria-describedbyat 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: reducethe modal fades without scaling.
Why the dialog lives in light DOM: ADR 0001.
Browser support
Section titled “Browser support”See Browser support for what is native, what falls back and which polyfills you may load.