Showing a Modal Dialog over Any Page
Open an accessible modal from an MV3 content script with dialog.showModal() in a shadow root: top-layer stacking, inert page, focus trapping and return, scroll locking, Escape handling and safe content.
Table of Contents
Some extension actions need the user’s full attention on the page they are looking at: “This form will send your password to a domain you have never visited — continue?”, a one-time consent screen, a quick sign-in before saving. A hand-built modal — a fixed div with a dark backdrop — looks right in a demo and then fails in production: the page’s header sits above it, Tab moves focus into the page behind it, the page scrolls under it, Escape does nothing, and screen reader users never learn it appeared. The native <dialog> element solves all of these when it is opened modally. This guide shows how to use it from a content script without the page interfering. It belongs to in-page overlays and injected UI.
What showModal gives you for free
dialog.showModal() does four things no amount of CSS can replicate on a page you do not control. It places the dialog in the top layer, above every element regardless of z-index or stacking context. It makes everything outside the dialog inert — not focusable, not clickable, hidden from assistive technology — so Tab cannot escape and clicks cannot reach the page. It closes the dialog on Escape, firing a cancellable cancel event first. And it announces the dialog to screen readers as a modal. Placed inside your closed shadow root, the dialog is also safe from the page’s styles. What it does not do is lock page scrolling, restore focus perfectly in every case, or protect you from untrusted content — those are yours.
Step-by-step: a reusable confirm dialog
1. Build the dialog from a fixed template
1// content script — root is your closed shadow root
2const TEMPLATE = `
3 <dialog aria-labelledby="dlg-title" aria-describedby="dlg-body">
4 <form method="dialog">
5 <h2 id="dlg-title"></h2>
6 <p id="dlg-body"></p>
7 <div class="actions">
8 <button value="cancel" type="submit">Cancel</button>
9 <button value="confirm" type="submit" class="primary">Continue</button>
10 </div>
11 </form>
12 </dialog>`;
13
14function buildDialog(root, { title, body, confirmLabel }) {
15 const wrap = document.createElement("div");
16 wrap.innerHTML = TEMPLATE;
17 const dlg = wrap.firstElementChild;
18 dlg.querySelector("#dlg-title").textContent = title;
19 dlg.querySelector("#dlg-body").textContent = body;
20 if (confirmLabel) dlg.querySelector(".primary").textContent = confirmLabel;
21 root.append(dlg);
22 return dlg;
23}
Execution context: the content script, rendering into its own shadow root. The template contains no variable data; titles and messages — which may include page URLs or form field names supplied by the page — are inserted with textContent, so they can never become markup. <form method="dialog"> closes the dialog on submit and sets dialog.returnValue to the clicked button’s value, with no JavaScript needed.
2. Open it modally and resolve on close
1export function confirmOnPage(root, opts) {
2 return new Promise((resolve) => {
3 const previouslyFocused = document.activeElement;
4 const dlg = buildDialog(root, opts);
5 dlg.addEventListener("close", () => {
6 resolve(dlg.returnValue === "confirm");
7 dlg.remove();
8 if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true });
9 }, { once: true });
10 dlg.showModal();
11 dlg.querySelector(opts.destructive ? "button[value=cancel]" : ".primary").focus();
12 });
13}
Execution context: the content script. The promise resolves true only for an explicit confirm; Escape and Cancel both resolve false. Returning focus to the element that had it — usually the button the user just pressed — is essential for keyboard and screen reader users and is not reliable across engines without doing it yourself. For destructive actions, focus the safe choice first so a reflexive Enter does not confirm.
3. Style the dialog and its backdrop inside the shadow root
1:host { all: initial; }
2dialog {
3 border: 0; border-radius: 12px; padding: 20px 24px; max-width: min(440px, calc(100vw - 32px));
4 font: 15px/1.5 system-ui, sans-serif; color: #111827; background: #fff;
5 box-shadow: 0 20px 50px rgb(0 0 0 / .3);
6}
7dialog::backdrop { background: rgb(17 24 39 / .55); }
8h2 { margin: 0 0 8px; font-size: 18px; }
9.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
10button { all: unset; padding: 8px 14px; border-radius: 8px; cursor: pointer; border: 1px solid #d1d5db; }
11button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
12button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }
13@media (prefers-color-scheme: dark) { dialog { background: #1f2937; color: #f9fafb; } }
Execution context: a stylesheet inside the shadow root. ::backdrop of a dialog in a shadow root is styled by the shadow root’s rules, not the page’s. Setting explicit colours rather than inheriting avoids white-on-white dialogs on dark-themed sites. max-width with calc keeps the dialog usable on narrow mobile viewports.
4. Lock page scrolling while open
1function lockScroll() {
2 const html = document.documentElement;
3 const prev = { overflow: html.style.overflow, paddingRight: html.style.paddingRight };
4 const scrollbar = innerWidth - html.clientWidth;
5 html.style.overflow = "hidden";
6 if (scrollbar > 0) html.style.paddingRight = `${scrollbar}px`; // avoid layout shift
7 return () => Object.assign(html.style, prev);
8}
Execution context: the content script. An inert page still scrolls with the wheel or touch in some engines, which moves the content the dialog is about out of view. Locking overflow on the root element and compensating for the vanished scrollbar prevents both scrolling and the jarring horizontal jump. Call the returned function in the close handler. Restore exactly what was there, because the page may have set its own inline styles.
5. Handle Escape deliberately
1dlg.addEventListener("cancel", (e) => {
2 if (opts.requireExplicitChoice) e.preventDefault(); // e.g. mandatory consent
3});
Execution context: the content script. Escape fires cancel before closing; preventing it keeps the dialog open. Use this sparingly — users expect Escape to dismiss — and only when dismissal without a choice would leave the feature in a broken state. Some engines allow a second Escape to close regardless, so always handle the “closed without choice” path.
6. Trigger it from the worker
1// sw.js
2export async function confirmInTab(tabId, title, body) {
3 try {
4 const { confirmed } = await chrome.tabs.sendMessage(tabId, { type: "confirm", title, body });
5 return confirmed === true;
6 } catch {
7 return false; // no content script on this page; decide a safe default
8 }
9}
Execution context: the service worker. If the tab has no content script — a restricted page, or a site without host access — sendMessage rejects; inject one with chrome.scripting under activeTab, or fall back to the popup or a notification. The content script’s message handler returns true to keep the channel open while the dialog waits for the user.
Common mistakes
- Using
dialog.show()instead ofshowModal(). The non-modal version does not use the top layer and does not make the page inert. - Inserting page data with
innerHTML. Form names, URLs and titles come from the page and can contain markup. UsetextContent. - Forgetting focus return. Keyboard users end up at the top of the document after closing.
- Blocking Escape by default. Trapped users close the tab. Allow Escape unless a choice is genuinely required.
- Opening dialogs unprompted. A modal that appears without a user action feels like malware. Tie every dialog to something the user just did.
Cross-browser variation
- Chrome / Edge: full modal dialog support including inertness and
::backdropin shadow roots. - Firefox: full support. Very old ESR versions had incomplete
inerthandling; current releases match Chrome. - Safari: supports modal dialogs and inertness from 15.4. On iOS, the scroll lock in step 4 needs
position: fixedonbodywith the scroll offset preserved, becauseoverflow: hiddenon the root does not stop touch scrolling everywhere.
Verification
- Open the dialog on a page with a high-z-index sticky header and confirm the dialog and backdrop cover it.
- Press Tab repeatedly and confirm focus cycles only through the dialog’s buttons.
- Press Escape and confirm the promise resolves
falseand focus returns to the triggering element. - With a screen reader, confirm the dialog is announced with its title and body.
- Try to scroll the page with the wheel and with a trackpad while the dialog is open: it should not move.
FAQ
Can the page close my dialog?
A page script cannot reach into a closed shadow root to call close(), but it can remove your host element from the DOM, which removes the dialog. Treat an unexpected removal as a cancel.
What if the page already has a modal open?
Both live in the top layer; the one opened last is on top and the other becomes inert. Usually that means your dialog appears above the site’s — check that this is appropriate before showing it.
Should I use the popup instead?
If the decision does not need the page’s context in view, the popup or a notification is less intrusive. Use an in-page modal when the user must see the page to decide.
Related
- Keeping injected UI above the page with the top layer — the stacking model the dialog relies on.
- Managing focus in popups and dialogs — focus rules in depth.
- Sanitising untrusted page data in an extension — keeping page data out of markup.
- In-page overlays and injected UI — the parent topic.