Building an accessible modal by hand means correctly implementing focus trapping, Escape handling, backdrop click-through, and top-of-stack rendering ā a surprisingly large amount of subtle logic. The <dialog> element implements all of it natively.
1show() vs showModal(): Modal And Non-Modal Dialogs
The <dialog> element supports two distinct open states. show() opens it non-modally: it appears in normal document flow (well, absolutely positioned by default, but still just another interactive element), the rest of the page remains fully interactive, and no backdrop is drawn. showModal() is the one developers reach for most: it renders the dialog on the browser's top layer, draws a ::backdrop behind it, and makes everything else in the document inert until it closes.
The distinction matters because non-modal dialogs suit persistent, non-blocking UI ā a "find in page" panel, a notification tray ā where the user should still be able to interact with the rest of the page, while modal dialogs suit anything that must fully block interaction until resolved, like a destructive-action confirmation.
2Closing A Dialog: close(), requestClose(), And form method="dialog"
Three ways exist to close a dialog, each suited to a different situation. dialog.close(returnValue?) closes it immediately and unconditionally from JavaScript. dialog.requestClose(returnValue?) closes it but first fires a cancelable cancel event, letting you intercept an Escape press or programmatic close attempt ā useful for an "are you sure you want to discard changes?" guard. And declaratively, a <form method="dialog"> inside the dialog closes it on submit with no JavaScript at all, setting dialog.returnValue to whichever submitting button's value attribute triggered it.
The close event fires after any of these three paths completes, making it the single reliable place to read returnValue and react ā regardless of whether the user clicked a button, pressed Escape, or your code called close() programmatically.
3Accessibility And Dismissal Strictness
showModal() gives you real, substantial accessibility behavior for free: focus moves into the dialog (to the first focusable element, or one marked autofocus), Tab cycles only among elements inside it, and screen readers correctly announce entering a dialog context. What it does NOT give you automatically is an accessible name ā every dialog still needs aria-labelledby pointing at a heading inside it (preferred) or an explicit aria-label, or assistive technology has nothing meaningful to announce.
The closedby attribute ("any", "closerequest", or "none") governs how easily a dialog can be dismissed. closedby="none" is appropriate for a mandatory action a user cannot skip (accepting required terms); the default modal behavior allows both Escape and, depending on browser, light-dismiss via an outside click ā appropriate for the vast majority of confirmation and form dialogs.
4Rendering Cost And Animating The Open/Close Transition
Because top-layer rendering is handled natively by the browser's compositor rather than a JS-managed portal and manual z-index stacking, a native <dialog> avoids an entire class of layout thrashing and stacking-context bugs common to hand-rolled modal libraries ā a meaningful, if often overlooked, performance and correctness win, particularly on lower-end devices where JS-driven focus traps can introduce jank.
Animating a dialog's entrance requires @starting-style because a closed dialog has display: none, giving a normal CSS transition no starting frame to animate from. Pairing @starting-style with transition-behavior: allow-discrete lets display itself participate in the transition timeline, producing a genuine fade/scale-in entrance and a matching exit animation entirely in CSS.
5Step-by-Step Breakdown
A Modal That Doesn't Need A JavaScript Library. For over a decade, building an accessible modal meant a JS library, a focus-trap hook, and manual aria-modal wiring. The native <dialog> element does all of it ā focus trapping, Escape-to-close, backdrop, and top-layer stacking ā with a single method call.
showModal() Puts The Dialog On The Top Layer. dialog.showModal() renders the element on the browser's top layer ā a rendering layer above everything else in the document, including elements with high z-index ā and automatically makes the rest of the page inert: unfocusable and unreachable by Tab until the dialog closes.
showModal() And The Top Layer. What happens to the rest of the page automatically when dialog.showModal() is called?
- āNothing ā the developer must manually disable background interaction
- āThe rest of the document becomes inert: unfocusable and unreachable until the dialog closes
- āThe rest of the document is removed from the DOM until the dialog closes
form method="dialog" Closes Without Any JavaScript. A <form method="dialog"> inside a dialog closes it on submit with zero JavaScript, setting dialog.returnValue to the value of whichever submitting button was activated ā the standard, declarative way to build confirm/cancel button pairs.
form method="dialog". What does a <form method="dialog"> do when submitted inside an open <dialog>?
- āCloses the dialog and sets dialog.returnValue to the submitting button's value, with no page navigation
- āSubmits and navigates the page like a normal form
- āDoes nothing without an explicit JavaScript submit handler
Built-In Accessibility: Focus Trap, Esc, And Labeling. showModal() automatically traps Tab focus inside the dialog and wires Escape to close it (firing a cancel event you can preventDefault to block, e.g. to warn about unsaved changes) ā but the dialog's accessible name is NOT automatic and still needs an explicit aria-labelledby or aria-label.
What showModal() Gives You For Free. Which of these does showModal() NOT handle automatically?
- āTrapping Tab focus inside the dialog
- āClosing the dialog when Escape is pressed
- āGiving the dialog an accessible name for screen readers
closedby Controls Light-Dismiss Behavior. The closedby attribute ("auto", "closerequest", or "none") controls whether a dialog can be dismissed by clicking outside it or pressing Escape ā giving fine control over how forceful a modal should be, from a lightly-dismissible menu-like dialog to one that only closes via an explicit in-dialog action.
closedby Attribute. You need a dialog that forces the user to click an explicit 'I Agree' button ā Escape and clicking outside must NOT close it. What do you set?
- āclosedby="none"
- āclosedby="any"
- āThis isn't possible without custom JavaScript
Animating Open/Close With @starting-style. Because a closed <dialog> has display: none, transitioning it used to be impossible ā CSS @starting-style plus allow-discrete on the transition-behavior property solves this, letting you define the 'from' state for the very first frame a top-layer element becomes visible.
Animating A Dialog's Entrance. Why can't a plain CSS transition alone animate a <dialog> fading in when it opens?
- āA closed dialog has display: none, so there's no 'before' state for the browser to transition from
- āOpacity is not an animatable property on top-layer elements
- āIt actually works fine with a plain transition, no special CSS needed
Dialog API Mastered. You now know how to build a fully accessible modal with <dialog>, showModal(), and form method="dialog" ā including controlling dismissal strictness with closedby and animating it in with @starting-style ā all without a JavaScript modal library.
Add A Native Dialog. The <dialog> element gives you a modal with built-in browser support.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1showModal() Handles Focus Trapping And Escape ā You Must Still Handle Labeling
Always pair a modal dialog with aria-labelledby (pointing at an in-dialog heading) or aria-label; the built-in focus management is meaningless to screen reader users without an announced name for the dialog context they've entered.
2Never Nest A <dialog> Trigger Button Without A Clear Post-Close Focus Return
The browser automatically returns focus to the element that had focus before showModal() was called, but verify this holds for dynamically-created trigger elements too.
SEO Implications
- 1
Modal Dialog Content Is Not Indexed As Page Content By Default
Content only ever revealed inside a showModal() dialog shouldn't be relied upon as a page's primary content for SEO purposes ā treat it as supplementary UI, not a substitute for crawlable body content.
Best Practices
Prefer form method="dialog" For Confirm/Cancel Button Pairs
It closes the dialog and records which button was pressed without any JavaScript submit handler, reducing the surface area for close-state bugs.
Use closedby="none" Sparingly, Only For Genuinely Mandatory Actions
Removing light-dismiss and Escape is a real usability cost ā reserve it for cases like mandatory legal consent where skipping the action isn't a valid user path.
Frequent Bugs
Calling dialog.show() (non-modal) when a fully blocking modal experience with a backdrop was intended.
Use showModal() for anything that should block interaction with the rest of the page and render a backdrop; show() is for non-blocking, persistent panels.
A dialog opens with no announced name for screen reader users, despite visible heading text.
Add aria-labelledby referencing the heading's id (or aria-label) ā visible text alone isn't automatically wired as the dialog's accessible name.
Real-World Examples
A Fully Accessible Confirmation Dialog
A destructive delete action guarded by a native, accessible confirmation dialog with zero JS focus-trap code.
<dialog id="delete-dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this item?</h2>
<p>This action can't be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm" autofocus>Delete</button>
</form>
</dialog>
<script>
const dlg = document.getElementById("delete-dialog");
dlg.addEventListener("close", () => {
if (dlg.returnValue === "confirm") performDelete();
});
</script>