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.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

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.

The four points where keyboard access breaksInitial focus, tab-stop count, escape handling and focus loss after a re-render each fail independently.Popup opensfocus is on <body>Tab count20 items = 20 stopsEscapedoes nothing by defaultRe-renderfocus jumps to <body>each has a small, specific fixfocus() on loadfirst meaningful controlRoving tabindexone stop, arrows insidekeydown handlerclose or clearRestore by keyrefocus the same item
Each one is a few lines; missing any one of them makes the surface feel broken rather than imperfect.

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.

One arrow keypress through a roving tabindexThe list handles the key, moves the single tabindex zero to the next row, focuses it, and the browser announces the newly focused option.UserList containerRowsScreen readerArrowDownpreventDefault — do not scroll tooold row tabIndex = -1, next = 0next.focus()option name and position announced
Exactly one row carries tabindex 0 at any moment — that is what keeps the list to one tab stop.
 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

What Escape should do, by surfaceIn a popup Escape closes; in an options page it clears the current field or dismisses a dialog; in injected page UI it must not steal the key from the page.Which surface is handling the key?PopupClose the popupwindow.close()State must already be savedwrite on change, not on closeOptions pageDismiss the innermost thingdialog, then filter, then nothingNever close the tabthe user did not ask for thatInjected UIOnly if focus is inside itotherwise let it throughCheck event.target ownershipand do not preventDefault blindly
The wrong answer in injected UI is to swallow Escape — the page may need it.
 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 unless preventDefault is 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 tabindex values, which reorder the entire page.

Verification

  1. Open the popup and press Tab once. Focus should be visible and on the control you intended.
  2. Tab through the whole surface and count the stops. A list of twenty rows should contribute one, not twenty.
  3. With focus in the list, press Home and End. The first and last rows should take focus and be announced.
  4. Type in the filter, trigger a background storage change, and confirm the caret does not move.
  5. Press Escape at each depth — open detail, active filter, plain list — and confirm the innermost thing closes each time.
  6. 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.

Other UI/UX Patterns & Interactive Components Resources