What Is a CSS Modal, and What Is It Not?
Plenty of overlays get called modals without earning the name. In CSS terms, a modal is built from HTML and CSS (the dialog element, the ::backdrop pseudo-element, fixed positioning) and shows focused content above the page, blocking everything underneath until the user dismisses it.
CSS covers the look and the layering. Opening and closing takes a few lines of JavaScript or a declarative HTML attribute, unless you settle for a plain overlay toggled through :target or :checked.
Designers know the pattern as a modal in UI design, and the CSS version only changes how it gets built. What makes a modal real is enforcement. The browser makes the rest of the page inert instead of just covering it.
Modal dialog vs non-modal dialog
A dialog opened with showModal() is the modal kind. The rest of the page becomes inert, Esc closes it by default, and assistive technology sees it as aria-modal=”true”.
Open one with show() or the open attribute and you get the non-modal kind. The page stays interactive, Esc does nothing by default, and the dialog is exposed as aria-modal=”false”.
Go non-modal only when users have to keep working behind the box, like with an inspector panel.
Modal vs popover and lightbox
A popover is the non-modal cousin. It also lands in the top layer, but an auto popover closes on an outside click or Esc and never blocks the page (MDN Web Docs).
Lightboxes are modals built around media. Hans Christian Reinl’s CSS Modal library, for example, adds gallery and HTML5 video plugins, and its modals stack when one opens from another.
The decision rule is short. If the user must respond before moving on, use a modal. If the content is closer to a hint or a menu (a tooltip, say), use a popover.
How the Dialog Element Works as a Modal
Call showModal() on a dialog and it lands in the top layer, gets a ::backdrop behind it, and turns every other element in the document inert (MDN Web Docs).
That one call replaces most of what div-based tutorials build by hand. Focus moves to the first focusable element inside the dialog, or to whatever carries the autofocus attribute. Escape closes it, and when several modals are open it only closes the last one shown. The browser exposes it with an implicit aria-modal=”true”, much like a custom dialog role, and the background goes inert, so clicks and tab stops skip it.
A div with role=”dialog” has to rebuild every one of those, and MDN says as much. Hyring’s modal tutorial, for instance, adds a document-level keydown listener for Escape, sets overflow: hidden on the body, and writes the ARIA attributes (role, aria-modal, aria-labelledby) by hand.
Scroll locking isn’t on MDN’s list of built-in behaviors, so it gets its own section further down.
One trap. Never put tabindex on the dialog itself. MDN warns against it because the dialog isn’t interactive and doesn’t take focus.
Top layer vs position fixed and z-index
Top layer elements stack last in, first out above all page content, and each one gets its own ::backdrop (MDN Web Docs). Nothing in the DOM tree can trap them.
A fixed overlay with a z-index is a different story, because it stays inside its ancestors’ stacking contexts. A parent with a transform becomes the containing block for a fixed child, so the modal can end up sitting below a sticky header however high the z-index climbs.
Native dialog ends the z-index arms race.
Which CSS Modal Method Should You Use?
Use the native dialog element with showModal(). It’s the only option here where the browser supplies the inert background, Escape handling and focus placement.
Several tutorials that rank for this topic teach the checkbox hack or :target, which is exactly why those techniques keep getting copied into production. Treat them as demo techniques.
If you want zero JavaScript to open and close, a dialog with commandfor does it, plus a showModal() click listener for older browsers. A :target overlay only makes sense on a static page where nobody needs Escape. For everything else, showModal().
Capability comparison
| Method | Opens without JavaScript | Escape closes | Background inert |
|---|
| Dialog with showModal() | No | Yes, built in | Yes, built in |
| Dialog with commandfor | Yes | Yes, built in | Yes, built in |
| :target overlay | Yes | Manual, needs JavaScript | Manual |
| Checkbox hack | Yes | Manual, needs JavaScript | Manual |
| Div overlay with JS toggle | No | Manual, needs JavaScript | Manual |
The native rows come from MDN’s dialog reference. The CSS-only rows follow the limits reported by The Admin Bar (no Escape, no backdrop click), and neither CSS-only method moves focus.
Pros and cons of each method
The native dialog with showModal() hands you the inert background, Escape and focus placement for free. What it costs is a JavaScript call to open it.
Adding commandfor removes even that. The dialog opens and closes without a script, even before JavaScript loads. It’s only newly available in Baseline terms, though, so older browsers need the showModal() fallback.
The :target overlay is pure CSS and a link. There’s no Escape key, no focus handling, and the URL fragment changes.
The checkbox hack is pure CSS too and leaves the URL alone, but it has the same missing Escape and focus handling, and the state lives in a form control.
A div overlay with a JS toggle works in any browser and gives you full control. Inert, Escape, focus and ARIA are all yours to build.
Browser support by method
Support dates decide how much fallback code each method needs.
- The dialog element has been supported across browsers since March 2022 and is Baseline widely available (MDN Web Docs).
- Invoker Commands (commandfor) became Baseline newly available in December 2025 (MDN Web Docs).
- closedby shipped in Chrome 134 (March 2025) and Firefox 141 (July 2025). Safari doesn’t have it, and Baseline marks it limited availability (WebDX web-features data).
- :target is Baseline widely available and has been supported across browsers since July 2015 (MDN Web Docs).
MDN’s closedBy reference carries the same limited availability label. Skeleton’s dialog docs list Firefox 136 for closedby, while the web-features dataset says 141, and the dataset is the primary source.
Until Safari ships closedby, a build that relies on it needs a click-listener fallback, which makes cross-browser compatibility checks part of the job.
How to Build a CSS Modal with the Dialog Element
Start with the markup, then wire up opening and closing, then style it.
- Write a dialog with a heading, the content and a close button, and point aria-labelledby at the heading.
- Put autofocus on the close button when nothing else needs immediate input, as MDN recommends.
- Add a trigger button that calls showModal() on click.
- Close it with close(), or put the actions inside a form with method=”dialog” so the browser closes the dialog and sets returnValue.
- Style the box with width and max-height, then style the ::backdrop.
<button id="open-modal">Open modal</button>
<dialog id="modal" aria-labelledby="modal-title">
<h2 id="modal-title">Subscribe</h2>
<p>Short content goes here.</p>
<button id="close-modal" autofocus>Close</button>
</dialog>
<script>
const modal = document.querySelector("#modal");
document.querySelector("#open-modal").addEventListener("click", () => modal.showModal());
document.querySelector("#close-modal").addEventListener("click", () => modal.close());
</script>
Center and size the modal
The browser already centers a modal dialog. The HTML Standard’s default rules set position: fixed, inset-block to 0 and margin: auto, and cap both max-width and max-height at calc(100% – 6px – 2em).
So the CSS you write is short. Set a width, cap the height, and leave positioning alone.
dialog {
width: min(32rem, calc(100% - 2rem));
max-height: min(40rem, 90svh);
padding: 1.5rem;
border: 0;
border-radius: 0.75rem;
}
A global reset such as * { margin: 0 } removes margin: auto and pushes the dialog to the top-left corner. The default max-height cap also leaves a visible gap on full-height modals, so set max-height: none (a fix documented by Simon Willison).
Watch display too. Putting display: flex on the dialog selector overrides the closed state, so scope it to dialog[open].
For the height cap, prefer svh over vh, since the small, large and dynamic CSS viewport units account for mobile browser toolbars and plain vh does not.
Style the backdrop
The default backdrop is nearly invisible. The HTML Standard’s default style sheet sets it to rgba(0, 0, 0, 0.1).
Replace it with a stronger color and add a blur. The backdrop-filter property has been Baseline newly available since September 2024, when Safari 18 shipped it (WebDX web-features data), so keep the solid color dark enough to work without the blur.
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
backdrop-filter: blur(4px);
}
The backdrop only exists when the dialog opens through showModal(). A dialog opened with show() or the open attribute has none, per MDN.
How to Build a CSS Modal Without JavaScript
A :target link and a checkbox toggle can both open a modal without JavaScript, and so can the commandfor attribute on a dialog button. Only that last one gives you a true modal.
The :target method
A link to #modal makes the element with that id the document’s target, and the :target pseudo-class styles it. A link to # closes it.
.modal:not(:target) { display: none; }
.modal:target { position: fixed; inset: 0; display: grid; place-items: center; }
One side effect is the URL fragment changing, so the Back button closes the modal. MDN notes that the target element updates on history navigation.
The limits are documented. The Admin Bar reports that Escape does not close it, and focus never moves into the modal or back to the trigger.
Backdrop clicks are where sources disagree. The Admin Bar’s build cannot close on a backdrop click, but Envato Tuts+ closes its modal that way by making the overlay itself an anchor that points back to the trigger.
Link-based overlays work for outside clicks. Escape still needs JavaScript.
The checkbox hack
A hidden checkbox holds the state, a label opens and closes it, and the :checked pseudo-class shows the modal. The modal has to follow the checkbox in the DOM so the sibling selector can reach it.
It shares the :target limits (no Escape, no focus handling) and adds a form control with no dialog meaning. For styling the control itself, see these CSS checkbox examples. Demos only, honestly.
Declarative dialog with commandfor
This is the only no-JavaScript route that keeps native modal behavior. A button with command=”show-modal” and commandfor pointing at the dialog’s id opens it modally, and command=”close” closes it (MDN Web Docs).
<button commandfor="my-dialog" command="show-modal">Open dialog</button>
<dialog id="my-dialog">
<p>No click handler needed.</p>
<button commandfor="my-dialog" command="close">Close</button>
</dialog>
Because the browser handles the command, the button works without waiting for a JavaScript bundle to download and run. Dialogs respond to show-modal, close and request-close.
Keep a small click listener as a fallback for browsers that predate the attributes.
Focus, Keyboard and Screen Reader Behavior
A native modal dialog contains focus at the page level only. Tab can still reach the browser’s own interface, and that’s deliberate.
It departs from the W3C pattern for modal dialogs, which keeps Tab inside the dialog.
Focus containment: ARIA pattern vs native behavior
Under the W3C ARIA Authoring Practices, Tab and Shift+Tab never move focus outside the dialog, and Tab on the last tabbable element wraps to the first.
A native dialog opened with showModal() behaves differently. Tab and Shift+Tab can reach the address bar, the tabs and extensions, while the page behind the dialog stays unreachable.
A WHATWG HTML issue (#8339) records why the native behavior stays. Keyboard users can open a new tab or change a browser setting without closing the dialog.
So skip the JavaScript focus trap on a native dialog. Add one only to div-based modals, where nothing else keeps focus off the page.
Where focus lands and where it returns
With long content, put tabindex=”-1″ on a static element at the top, such as the title, and focus that, so the start of the text does not scroll out of view. For a destructive action, focus the least destructive button, like Cancel. A simple confirmation can focus the most used control, such as OK or Continue.
All of that comes from the W3C pattern notes. The earlier tabindex warning applies to the dialog element itself, not to a static child inside it.
On close, focus should return to the invoking element unless it no longer exists. The HTML Standard tells browsers to restore focus to the element that had it before the dialog opened, and UXPin says the same of close(). That only works when the element still exists and can take focus.
A three-line close listener removes the doubt.
let trigger;
openBtn.addEventListener("click", () => { trigger = document.activeElement; modal.showModal(); });
modal.addEventListener("close", () => trigger?.focus());
Dismissal with closedby
| closedby value | Esc or back gesture | Outside click |
|---|
| any | Closes | Closes |
| closerequest | Closes | Does nothing |
| none | Does nothing | Does nothing |
Without the attribute, a dialog opened with showModal() behaves as closerequest (MDN Web Docs). Esc fires a cancel event first, which code can prevent in some cases.
Outside-click dismissal alone fails keyboard and touch users, who may have no equivalent gesture. Ship a visible close button every time, as the W3C pattern strongly recommends.
Labels for screen readers
Name the dialog through aria-labelledby pointing at a visible title, or through aria-label.
The W3C pattern makes aria-describedby optional and advises skipping it when the content has lists, tables or several paragraphs, because screen readers announce a description as one unbroken string. Mark a custom dialog aria-modal=”true” only when code blocks all interaction outside it and the styling obscures the page. A native dialog meets both conditions by default.
How to Stop the Page Scrolling Behind a Modal
Call showModal() and the page behind the dialog stops accepting clicks, but it can still scroll. Scroll locking is a separate CSS job, and OpenReplay’s scroll-lock guide puts the split plainly: the method blocks interaction, not background scrolling.
Lock the page with overflow
Set overflow: hidden on the root while a dialog is open. Using :has() ties the lock to the open attribute that showModal() sets, so JavaScript has nothing to clean up (Frontend Masters).
html:has(dialog[open]) { overflow: hidden; }
It isn’t bulletproof. Touch scrolling on iOS Safari can bypass overflow: hidden on the body (OpenReplay). And a page with the html element set to overflow-y: scroll keeps scrolling when only the body is locked, so target html instead.
For iOS Safari, OpenReplay’s fallback is a fixed-position body that saves and restores the scroll position.
Contain scroll chaining on the dialog
overscroll-behavior: contain stops scrolling inside the dialog from spilling into the page. MDN and the web-features dataset list the property as limited availability, so it is not yet Baseline: Safari’s support, since version 16, is partial and ignores containers with no scrollable overflow. Chrome 144 and Firefox 150 dropped that same limit.
Sources conflict on whether it works. Frontend Masters tested it on the dialog and the ::backdrop and saw no lock, while OpenReplay says it prevents chaining but not scrolling that starts on the backdrop or the body.
Both can be true. The property stops chaining, and it does not lock a page the user scrolls directly. Older engines also ignored it on a dialog or backdrop with nothing to scroll. With Safari support still partial, treat it as a layer on top of the overflow lock, never a replacement for it.
Prevent layout shift when the scrollbar disappears
According to the web-features dataset, scrollbar-gutter has been Baseline newly available since December 2024, when Safari 18.2 shipped it. Setting it to stable reserves the scrollbar’s space, so locking the page does not push content sideways.
Overlay scrollbars have no gutter, so the property does nothing there (MDN Web Docs). When no scrollbar shows, it leaves a blank strip down the side (Frontend Masters), and fixed-position elements do not always respect the reserved gutter.
Apply it to html rather than body, and test any fixed header.
html { scrollbar-gutter: stable; }
html:has(dialog[open]) { overflow: hidden; }
dialog { overscroll-behavior: contain; }
How to Animate a Modal Opening and Closing
Animating a dialog needs @starting-style for the entry state and a transition on display with allow-discrete. A transition on overlay keeps the dialog in the top layer while it exits (MDN Web Docs).
@starting-style has been Baseline newly available since August 2024, when Firefox 129 shipped it, according to the web-features dataset. The overlay property is the weak link. The same dataset lists it as limited availability, supported only in Chrome and Edge 117 and later.
Treat overlay as progressive enhancement. Without these features the dialog still opens, it just appears without the transition (web.dev). The same rules apply as for any other CSS animation: keep it short and optional.
dialog {
opacity: 0;
transform: translateY(1rem);
transition: opacity 0.2s ease-out, transform 0.2s ease-out,
overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; transform: none; }
@starting-style {
dialog[open] { opacity: 0; transform: translateY(1rem); }
}
dialog::backdrop {
background: rgb(0 0 0 / 0);
transition: background 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open]::backdrop { background: rgb(0 0 0 / 0.6); }
@starting-style {
dialog[open]::backdrop { background: rgb(0 0 0 / 0); }
}
Write allow-discrete inside the transition shorthand. If transition-behavior comes before the shorthand, the browser ignores it (web.dev).
Keyframes instead of transitions
Keyframe animations are simpler on the dialog and weaker on the backdrop. With CSS keyframes you skip @starting-style and allow-discrete, and a display value placed in a keyframe holds for the whole animation.
The catch is the backdrop. It can’t fade out this way, because the browser removes it the moment the dialog closes (MDN Web Docs).
Duration and reduced motion
MDN’s demos run 0.7s, which is slow for a component people open often. Drop it to around 200ms.
prefers-reduced-motion has been supported in every major browser since January 2020 and is Baseline widely available, and MDN lists scaling and panning of large objects among the vestibular motion triggers. WCAG 2.3.3 asks that interaction-triggered motion can be switched off, at Level AAA.
Inside a media query for the reduce setting, drop the transform from the transition list and keep a short fade.
@media (prefers-reduced-motion: reduce) {
dialog {
transition: opacity 0.1s linear, overlay 0.1s allow-discrete, display 0.1s allow-discrete;
}
}
When a CSS Modal Falls Short
A CSS modal is the wrong tool when the content isn’t critical, when it blocks the first view of a page, when users hold unsaved input, or when the form sits on a phone with the keyboard open.
Content that does not deserve an interruption
Nielsen Norman Group’s 2017 guidance lists the costs: a modal demands immediate attention, interrupts the workflow, adds a dismissal task and blocks background content.
That price is worth paying for critical warnings and input required to continue. It isn’t worth paying for newsletter prompts, or for upsells that need information the modal hides. A flow that takes several steps probably deserves a full page.
NN/g’s example is Walmart.com, whose older checkout used a modal login prompt and later dropped it.
Landing pages and search visibility
Google Search Central says intrusive dialogs and interstitials make content harder for search engines to understand, and it recommends small banners over overlays that obscure the whole page (documentation updated December 2025).
The case to avoid is a modal that opens as soon as someone reaches a landing page from a search result. NN/g’s study caught GoodHousekeeping.com showing a newsletter modal within 3 seconds of a first homepage visit, over a full-screen photo that removed all context.
Age gates are exempt from Google’s guidance. Google still suggests overlaying the content so some of it stays indexable.
Unsaved input and forced closing
The cancel event lets code stop Escape from closing a dialog, but only in some cases.
Browsers added an anti-abuse rule. The cancel event fires only after user activation, and a second close request with no activation in between goes through (WICG CloseWatcher explainer). Google’s Material Web project tracks the same gap as an open issue, listing the Android back button and a double Escape as gestures that cannot be canceled.
Never rely on preventDefault to protect form data. Save drafts while the user types.
Forms and mobile keyboards
The svh, lvh and dvh units do not react to the on-screen keyboard by default, so a modal capped in those units can still hide a focused field behind the keyboard.
Keep form modals short, let the content scroll inside the dialog, and move long forms to their own page. Test every form modal on a real phone.
CSS Modal FAQ
Does ::backdrop inherit styles from the dialog element?
Yes, in current browsers. The ::backdrop pseudo-element inherits from its originating element, so it can read custom properties set on the dialog itself.
Support arrived in Firefox 120, Chrome and Edge 122, and Safari 17.4, according to Chrome for Developers. Older versions needed a :root, ::backdrop selector to share variables.
How does a dialog report which button the user pressed?
Put a form with method=”dialog” inside the dialog. Submitting it closes the dialog without sending data anywhere, and returnValue takes the value of the submitting button.
Read that string in the close event. Calling close(“save”) sets the same property from script.
Can you nest a modal inside another modal?
Technically yes, but avoid it. Calling showModal() on a second dialog stacks it above the first in the top layer, whatever z-index values the page uses.
Bootstrap’s documentation rejects nested modals as a poor user experience, and the a11y-dialog library supports them while calling them questionable. Swap the content inside one modal, or open modals in sequence.
What CSS makes a modal full screen?
Reset the browser’s size caps, then fill the viewport. Set max-width: none and max-height: none, then add width: 100%, height: 100% and margin: 0 to the dialog.
Keep a visible close button. A full-screen modal leaves no backdrop to click.
Is a native CSS modal better than a Bootstrap modal?
Native wins for new builds, and Bootstrap wins on sites already using it. Bootstrap’s modal runs on HTML, CSS and JavaScript, adds role=”dialog” through script, supports one modal at a time, and ignores the autofocus attribute.
The dialog element supplies top-layer placement and an inert background itself, with no library.
Checks to Run Before Shipping a CSS Modal
A CSS modal ships safely once a keyboard user can open it, leave it and land back on the button that opened it. Test in that order, because the severity of a failure sets the priority.
Keyboard open, close and focus return come first. Scroll lock is next, along with form fields when a phone keyboard is open. The exit animation and the reduced-motion fallback go last.
Combining the W3C dialog pattern with the animation support data gives that sequence. The first group of failures blocks a task, the second disrupts one, and the third costs only polish.
The trade-off is a plain, instant modal on the first release, with motion added after the other checks pass.
As of October 26, 2026, Safari has not shipped closedby (MDN’s compatibility data lists it only in Safari Technology Preview), so light dismiss still needs a script there. Revisit that line of the checklist when it does.
A site-wide pass with a web accessibility checklist covers the rest of the page around the modal.