Making Popups and Options Keyboard Navigable
Fix focus order in MV3 surfaces with no browser chrome: initial focus on popup open, roving tabindex for lists, Escape handling, and focus that survives a re-render.
Table of Contents
- Root cause: a popup is a document with no chrome and a very short life
- Step 1. Put focus somewhere useful on open
- Step 2. Collapse long lists to one tab stop
- Step 3. Make Escape do the obvious thing
- Step 4. Do not lose focus when the list re-renders
- Step 5. Check the result with the keyboard only
- Cross-browser variation
- Verification
- FAQ
- Related
Open your popup and press Tab. If the first press lands on something invisible, or if it takes four presses to reach the button everyone clicks, the surface is unusable without a mouse — and unlike a web page, there is no address bar or browser chrome to fall back to. The popup is the entire keyboard context, and it starts with focus on <body>. This page is part of Internationalization & Accessibility and walks through the four fixes that make an extension surface keyboard-complete.
Root cause: a popup is a document with no chrome and a very short life
A browser tab gives keyboard users an address bar, a tab strip and a predictable Escape behaviour. An extension popup gives them a floating document that appears with focus nowhere in particular, can be dismissed by the browser at any moment, and is destroyed completely when it closes — so nothing about focus persists. Every convenience the browser normally supplies has to be built.
Step 1. Put focus somewhere useful on open
Do not rely on the autofocus attribute alone — it is honoured inconsistently in extension documents, and it cannot express “the first control that is currently enabled”.
1// popup.js
2const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
3
4function focusFirst() {
5 const preferred = document.querySelector("[data-autofocus]");
6 const target = preferred?.matches(FOCUSABLE) ? preferred : document.querySelector(FOCUSABLE);
7 target?.focus({ preventScroll: true });
8}
9
10document.addEventListener("DOMContentLoaded", focusFirst);
Execution context: Popup document, an ordinary page created fresh on every open; the script is external because inline <script> is refused by the extension content security policy. preventScroll stops a focus call at the bottom of a scrollable popup from jumping the view. Chrome and Firefox both open the popup with focus on <body>, so without this the first Tab lands on whatever the browser considers first — often a skip target the user cannot see. Safari occasionally leaves focus in the page behind the popup, which makes the explicit call mandatory rather than a nicety.
Step 2. Collapse long lists to one tab stop
A list of tabs, bookmarks or rules is the common case, and giving each row its own tab stop buries every control below it.
1// popup-list.js
2const list = document.getElementById("groups");
3
4export function renderList(items, activeId) {
5 list.replaceChildren(...items.map((item) => {
6 const el = document.createElement("li");
7 el.role = "option";
8 el.id = `opt-${item.id}`;
9 el.textContent = item.name;
10 el.tabIndex = item.id === activeId ? 0 : -1; // exactly one 0
11 el.setAttribute("aria-selected", String(item.id === activeId));
12 return el;
13 }));
14 list.setAttribute("aria-activedescendant", `opt-${activeId}`);
15}
16
17list.addEventListener("keydown", (event) => {
18 const items = [...list.children];
19 const from = items.indexOf(document.activeElement);
20 if (from === -1) return;
21
22 const to = { ArrowDown: from + 1, ArrowUp: from - 1, Home: 0, End: items.length - 1 }[event.key];
23 if (to === undefined) return;
24
25 event.preventDefault();
26 const target = items[Math.max(0, Math.min(items.length - 1, to))];
27 items.forEach((el) => { el.tabIndex = el === target ? 0 : -1; });
28 target.focus();
29});
Execution context: Popup or side panel document. The list container carries role="listbox" in the markup and each row role="option", which is what makes aria-selected and arrow-key movement the expected interaction rather than a custom one a screen-reader user has to learn. preventDefault on the arrow keys stops the popup also scrolling, a double movement that is very visible in Chrome’s small popup viewport. The same pattern works unchanged in an options page, where lists are usually longer.
Step 3. Make Escape do the obvious thing
1// popup.js
2document.addEventListener("keydown", (event) => {
3 if (event.key !== "Escape") return;
4
5 // Innermost first: an open detail panel, then a filter, then the popup itself.
6 const openPanel = document.querySelector("details[open]");
7 if (openPanel) { openPanel.open = false; return; }
8
9 const filter = document.getElementById("filter");
10 if (filter?.value) { filter.value = ""; filter.dispatchEvent(new Event("input")); return; }
11
12 window.close();
13});
Execution context: Popup document. window.close() works in a popup in all three engines because the document owns its own window; it does nothing useful in an options page opened as a tab, where closing the user’s tab would be hostile. Dispatching a synthetic input event after clearing the filter keeps the single write path intact rather than duplicating the filter logic — the same argument as in validating and resetting options forms.
Step 4. Do not lose focus when the list re-renders
Extension surfaces re-render on every storage change, and replaceChildren destroys the focused node. The user is typing, a background sync lands, and focus is silently back on <body>.
1// popup-list.js
2export function renderPreservingFocus(items, activeId) {
3 const focusedId = document.activeElement?.id;
4 const selectionStart = document.activeElement?.selectionStart ?? null;
5
6 renderList(items, activeId);
7
8 if (!focusedId) return;
9 const restored = document.getElementById(focusedId);
10 if (!restored) { focusFirst(); return; } // the node genuinely went away
11
12 restored.focus({ preventScroll: true });
13 if (selectionStart !== null && "setSelectionRange" in restored) {
14 restored.setSelectionRange(selectionStart, selectionStart);
15 }
16}
Execution context: Popup or options document, called from the storage onChanged listener that drives re-rendering. Stable element ids are what make this possible, which is a good reason to key rows by a domain id rather than an array index. Restoring the caret position matters in a filter field: without it, a background sync moves the cursor to the end of the text mid-word, which users report as “the extension eats my typing”.
Step 5. Check the result with the keyboard only
Automated tools catch missing names and bad contrast. They do not catch an order that is technically valid and practically unusable, so the last step is manual.
1// A development-only overlay that numbers the tab stops in order.
2if (import.meta.env?.DEV) {
3 [...document.querySelectorAll(FOCUSABLE)].forEach((el, i) => {
4 const tag = document.createElement("span");
5 tag.textContent = String(i + 1);
6 tag.style.cssText = "position:absolute;font:10px/1 monospace;background:#2563eb;color:#fff;padding:1px 3px";
7 el.style.position ||= "relative";
8 el.append(tag);
9 });
10}
Execution context: Popup document, development builds only — ship it and you have added stray text to every control’s accessible name, which is worse than the problem it diagnoses. Numbering the stops makes an unexpected order obvious at a glance. Combine it with a real screen reader pass: VoiceOver on macOS covers Safari, NVDA on Windows covers Chrome and Firefox, and both will find names the numbering does not.
Cross-browser variation
- Chrome/Edge: popup opens with focus on
<body>.window.close()dismisses it. Arrow keys scroll the popup unlesspreventDefaultis called. - Firefox: same initial focus behaviour. Its options page can be embedded in the add-ons manager, where your document is inside an iframe — Escape there must not try to close a window it does not own.
- Safari: focus may remain in the underlying page when the popup opens, so the explicit
focus()call is required. Safari also applies system text scaling, which can push controls out of view and make focus order feel different at large sizes. - All engines: injected page UI shares the tab order with the host page. Insert your controls where they make sense in document order rather than fixing it with positive
tabindexvalues, which reorder the entire page.
Verification
- Open the popup and press Tab once. Focus should be visible and on the control you intended.
- Tab through the whole surface and count the stops. A list of twenty rows should contribute one, not twenty.
- With focus in the list, press Home and End. The first and last rows should take focus and be announced.
- Type in the filter, trigger a background storage change, and confirm the caret does not move.
- Press Escape at each depth — open detail, active filter, plain list — and confirm the innermost thing closes each time.
- Run the surface through a screen reader and confirm every control announces a name and a role.
FAQ
Should I use positive tabindex to fix the order?
No. Any positive value moves the element ahead of every element with tabindex="0" in the whole document, which reorders far more than you intended. Fix the DOM order instead.
Is a focus trap needed in a popup?
No — the popup is already the only focusable surface while it is open, and the browser handles dismissal. Traps are for dialogs inside a larger document, such as a modal in an options page.
Why does my focus outline disappear?
A CSS reset removed it. Use :focus-visible to style it rather than removing it, and check it against the popup background in both themes.
Do I need aria-activedescendant as well as roving tabindex?
Not both for the same widget. Roving tabindex moves real focus and is simpler to get right; aria-activedescendant is for widgets where focus must stay in a text input while a list selection moves.
Related
- Announcing dynamic updates to screen readers — telling the user what changed after these interactions.
- Localising extension UI with the i18n API — where the accessible names come from.
- Building a tabbed options page layout — the tablist pattern with its own keyboard contract.
- Internationalization & Accessibility — the guide this page belongs to.