Modal dialogs interrupt the page so a user can complete a short decision without losing their place. Start with the native HTML <dialog> element, open it with showModal(), and add only the behavior the page needs around that built-in modal state.
The native modal dialog keeps the rest of the page unavailable while it is open, limits keyboard focus to the dialog, and supports Escape as a close request in modern browsers. JavaScript still needs to remember the invoking control, move initial focus to a useful element, handle pointer dismissal when the design allows it, and report or process the result after the dialog closes.
Native dialog modals fit small in-page tasks such as confirming a setting, editing a short form, or collecting one focused choice. Keep a visible close or cancel button inside the dialog, and test the mouse path and keyboard path before treating the modal as accessible.
Related: Use the HTML dialog element
Related: Add event listeners in JavaScript
<button type="button" id="open-plan-dialog" data-open-modal> Change plan </button> <p id="modal-status" role="status" data-modal-status> No dialog action yet. </p>
The trigger must be a real button so mouse, touch, Enter, and Space activation reach the same JavaScript listener.
<dialog class="modal" id="plan-dialog" aria-labelledby="plan-dialog-title" aria-describedby="plan-dialog-description" data-modal > <form method="dialog" class="modal__panel"> <h2 id="plan-dialog-title" tabindex="-1" data-modal-title> Change plan </h2> <p id="plan-dialog-description"> Select the plan level that should be saved for this account. </p> <label for="plan-level">Plan level</label> <select id="plan-level" name="plan-level"> <option>Starter</option> <option>Team</option> <option>Business</option> </select> <div class="modal__actions"> <button id="cancel-plan-dialog" type="submit" value="cancel"> Cancel </button> <button id="save-plan-dialog" type="submit" value="save"> Save plan </button> </div> <button class="modal__close" id="close-plan-dialog" type="submit" value="cancel" > Close </button> </form> </dialog>
The method="dialog" form lets the dialog buttons close the modal and set dialog.returnValue without a custom submit handler.
.modal { border: 0; border-radius: 0.75rem; box-shadow: 0 1.25rem 3rem rgb(15 23 42 / 0.35); max-inline-size: 34rem; padding: 0; } .modal::backdrop { background: rgb(15 23 42 / 0.68); } .modal__panel { display: grid; gap: 1rem; padding: 1.5rem; position: relative; } .modal__close { position: absolute; inset-block-start: 1rem; inset-inline-end: 1rem; } .modal h2 { margin-inline-end: 5rem; } .modal__actions { display: flex; gap: 0.75rem; justify-content: end; } :focus-visible { outline: 3px solid #2563eb; outline-offset: 3px; } .modal h2:focus { outline: 3px solid #2563eb; outline-offset: 0.35rem; }
A visible ::backdrop supports the modal state visually while showModal() blocks interaction outside the dialog.
const dialog = document.querySelector("[data-modal]"); const openButton = document.querySelector("[data-open-modal]"); const title = dialog.querySelector("[data-modal-title]"); const status = document.querySelector("[data-modal-status]"); let lastFocusedElement = null;
openButton.addEventListener("click", () => { if (dialog.open) { return; } lastFocusedElement = document.activeElement; dialog.returnValue = ""; dialog.showModal(); title.focus({ preventScroll: true }); });
Focusing the title gives keyboard and screen reader users a stable starting point. Move focus to the first form control instead when the dialog is only a short data-entry form and the title is already clear from context.
dialog.addEventListener("pointerdown", (event) => { if (event.target !== dialog) { return; } const dialogBox = dialog.getBoundingClientRect(); const clickedInDialog = event.clientX >= dialogBox.left && event.clientX <= dialogBox.right && event.clientY >= dialogBox.top && event.clientY <= dialogBox.bottom; if (!clickedInDialog) { dialog.close("dismiss"); } }); dialog.addEventListener("close", () => { const result = dialog.returnValue || "dismiss"; status.textContent = `Dialog closed with: ${result}`; if (lastFocusedElement instanceof HTMLElement) { lastFocusedElement.focus({ preventScroll: true }); } });
The close event runs after Escape, method="dialog" buttons, and the backdrop pointer handler above, so one focus-restoration path covers all close actions.
Dialog open: true Focus after open: plan-dialog-title Tab sequence inside dialog: plan-level, cancel-plan-dialog, save-plan-dialog, close-plan-dialog Focus stayed inside dialog after Tab: true Dialog open after Escape: false Focus after Escape: open-plan-dialog
Dialog closed with: save
The status message proves the close button value reached dialog.returnValue, while the returned focus on Change plan proves the page is ready for the next keyboard action.