Managing Focus in Popups and Dialogs
Manage keyboard focus in extension popups, side panels and in-page dialogs: initial focus when the popup opens, the native dialog element, focus trapping, restoring focus after close, and focus inside shadow DOM.
Table of Contents
- Where focus goes in each extension surface
- Step-by-step: deliberate focus
- 1. Focus the primary control when the popup opens
- 2. Use the native dialog element for modals
- 3. Restore focus after removing content
- 4. Move focus into the side panel on explicit request
- 5. Handle focus for dialogs injected into pages
- 6. Account for shadow DOM when reading focus
- 7. Keep a logical tab order
- 8. Test with only a keyboard
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
A keyboard user presses the extension’s shortcut, the popup opens — and nothing is focused, so the first Tab press moves focus somewhere unexpected, and a screen reader announces “document” with no hint of what to do. They open a confirmation dialog inside the popup, press Tab three times, and focus escapes to the content behind it. They close the dialog and focus jumps back to the top. Focus management is the difference between UI that works with a keyboard and UI that technically contains focusable elements. Extension surfaces have their own quirks: the popup opens from the toolbar or a shortcut, injected dialogs live inside someone else’s page, and shadow DOM changes how focus is reported. This guide covers each surface. It belongs to internationalization and accessibility.
Where focus goes in each extension surface
When the popup opens, the browser moves focus into the popup document, but which element receives it is up to you — without autofocus or a script, nothing inside is focused and the first Tab picks the first focusable element. Side panels do not take focus when they open; the user’s focus stays on the page until they move it. Options pages and extension tabs behave like normal web pages. UI injected by a content script is in the page’s document, competing with the page’s own focus handling. In every case, the rules are the same as the web’s: put focus somewhere sensible on open, keep it inside modal UI while it is open, and return it to where it came from when the UI closes.
Step-by-step: deliberate focus
1. Focus the primary control when the popup opens
1<!-- popup.html -->
2<input id="search" type="search" aria-label="Search saved pages" autofocus>
1// popup.js — when the target depends on state
2const target = state.items.length ? document.querySelector("#search") : document.querySelector("#get-started");
3requestAnimationFrame(() => target.focus());
Execution context: the popup page. autofocus is enough for a fixed target; a script lets the first-run popup focus “Get started” and the regular popup focus search. Focusing after the first frame avoids a race with the popup’s sizing. Pick the control most users want first — usually search, the main action, or the first item — not the close button.
2. Use the native dialog element for modals
1<dialog id="confirm-delete" aria-labelledby="cd-title">
2 <h2 id="cd-title">Delete this page?</h2>
3 <p>This removes it from every synced device.</p>
4 <form method="dialog">
5 <button value="cancel" autofocus>Cancel</button>
6 <button value="delete" class="danger">Delete</button>
7 </form>
8</dialog>
1const dialog = document.querySelector("#confirm-delete");
2let opener;
3function confirmDelete(button) {
4 opener = button;
5 dialog.showModal();
6}
7dialog.addEventListener("close", () => {
8 if (dialog.returnValue === "delete") deleteItem(opener.dataset.id);
9 opener?.focus();
10});
Execution context: the popup, options page or side panel. showModal() makes the rest of the page inert, traps Tab inside the dialog, closes on Escape, and exposes the right roles to assistive technology — no focus-trap library needed. autofocus on Cancel makes the safe choice the default for a destructive action. The close handler restores focus to the button that opened it.
3. Restore focus after removing content
1async function removeItem(li) {
2 const next = li.nextElementSibling ?? li.previousElementSibling;
3 li.remove();
4 (next?.querySelector("button") ?? document.querySelector("#search")).focus();
5 announce(chrome.i18n.getMessage("itemRemoved"));
6}
Execution context: the popup. When the focused element is removed, focus falls to <body> and keyboard users lose their place. Moving focus to the neighbouring item keeps them in the list. Pair it with a polite live-region announcement, as in announcing dynamic updates to screen readers.
4. Move focus into the side panel on explicit request
1// sidepanel.js
2chrome.runtime.onMessage.addListener((msg) => {
3 if (msg.type === "focus-panel") document.querySelector("#notes").focus();
4});
5// service worker: after a command opens the panel
6chrome.commands.onCommand.addListener(async (cmd, tab) => {
7 if (cmd !== "open-notes") return;
8 await chrome.sidePanel.open({ tabId: tab.id });
9 chrome.runtime.sendMessage({ type: "focus-panel" }).catch(() => {});
10});
Execution context: the side panel page and service worker. A side panel opened by a keyboard shortcut should put focus where the user can type; one opened as a passive companion should not steal focus from the page. Let the trigger decide: commands move focus, background updates do not.
5. Handle focus for dialogs injected into pages
1// content script
2function openInPageDialog(contentNode) {
3 const previous = document.activeElement;
4 const host = document.createElement("div");
5 const root = host.attachShadow({ mode: "closed" });
6 const dialog = document.createElement("dialog");
7 dialog.setAttribute("aria-label", "Save to Readable");
8 dialog.append(contentNode);
9 root.append(styleSheet(), dialog);
10 document.documentElement.append(host);
11 dialog.showModal();
12 dialog.addEventListener("close", () => { host.remove(); previous?.focus?.(); }, { once: true });
13}
Execution context: a content script. showModal() works inside a shadow root and places the dialog in the top layer, above the page’s own stacking contexts, while making the page inert. Remember the page’s previously focused element and return focus to it when the dialog closes, so the user resumes where they were in the page. See showing a modal dialog over any page.
6. Account for shadow DOM when reading focus
1function deepActiveElement(root = document) {
2 let el = root.activeElement;
3 while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
4 return el;
5}
Execution context: extension pages or content scripts using web components. document.activeElement reports the shadow host, not the focused element inside it. Walk down through open shadow roots to find the real one; for closed roots, keep a reference yourself.
7. Keep a logical tab order
Never use positive tabindex values; arrange the DOM in reading order and let Tab follow it. Use tabindex="-1" for elements you focus programmatically (headings after navigation, error summaries) but which should not be Tab stops. For composite widgets like a list of results, consider a roving tabindex so Tab enters and leaves the list in one step while arrow keys move within it.
8. Test with only a keyboard
Unplug the mouse — figuratively — and complete each task: open the popup with its shortcut, search, act on an item, open and dismiss a dialog, open the side panel. At each step, know where focus is. Repeat with a screen reader as described in testing extension UI with a screen reader.
Common mistakes
- No initial focus in the popup. The first Tab lands somewhere arbitrary.
- Custom modal divs. Focus escapes; use
<dialog>withshowModal(). - Not restoring focus on close. Users lose their place.
- Stealing focus for passive updates. Announce instead.
- Positive tabindex. Breaks natural order.
Cross-browser variation
- Chrome / Edge: popup receives focus on open;
<dialog>and top layer fully supported, including in shadow roots. - Firefox: same behaviour; the sidebar does not take focus on open.
- Safari: popovers receive focus on open;
<dialog>is supported in current versions. Keyboard navigation of links requires the user to enable “Press Tab to highlight each item” in Safari settings.
Verification
- Open the popup by shortcut and confirm focus is on the intended control.
- Open a dialog, press Tab repeatedly and confirm focus stays inside; press Escape and confirm focus returns to the opener.
- Delete an item and confirm focus moves to its neighbour.
- Open an in-page dialog and confirm focus returns to the page element afterwards.
FAQ
Should the popup close button get initial focus?
No. Focus the main control; Escape closes the popup anyway.
Do I need a focus-trap library?
Not with <dialog> and showModal(). Libraries are for non-modal patterns that need custom containment.
Why does focus go to the page after my side panel opens?
Side panels do not take focus by default. Move it explicitly when the user opened the panel by command.
Can a content script focus page elements?
Yes, it shares the page’s DOM, but only do so in direct response to the user, and restore what was focused before.
Related
- Making popups and options keyboard-navigable — keyboard operation.
- Announcing dynamic updates to screen readers — when not to move focus.
- Showing a modal dialog over any page — injected dialogs.
- Internationalization and accessibility — the parent topic.