Testing Extension UI with a Screen Reader

Test a popup, options page and injected UI with NVDA, VoiceOver and axe — reaching the popup by keyboard, what to listen for, and automating the checks that do not need a human.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Automated accessibility checkers catch perhaps a third of real problems. They will flag a missing label; they will not notice that your popup announces “button, button, button” because every icon button has the same accessible name, or that focus lands on a close control the moment the options page opens. Extensions add their own twist: the popup opens from a toolbar most screen-reader users reach by a keyboard shortcut they may not know, and injected UI lives inside someone else’s page. This guide is part of internationalisation and accessibility.

What each kind of test catches

Automated checks against screen-reader testingSix categories of accessibility defect compared on whether axe-core catches them automatically and whether a manual screen-reader pass catches them.Defectaxe-coreScreen-reader passMissing label or altYesYesInsufficient contrastYesNoDuplicate ids / broken ARIAYesSometimesUnhelpful names ("button")NoYesFocus order and trapsPartlyYesSilent dynamic updatesNoYes
The bottom three rows are why a human pass is not optional — they are about meaning and flow, not markup.

Step-by-step

1. Reach the popup without a mouse

Screen-reader users open an extension the way keyboard users do. Confirm there is a path, and document it.

  • Chrome / Edge: Alt+Shift+A-style shortcuts are not assigned by default. The reliable route is to give the action a _execute_action command so a shortcut opens the popup.
  • Firefox: the same _execute_action command; users can also reach toolbar buttons with F6 and arrow keys, which is slower.
  • Safari: extension buttons are reachable through the toolbar with VoiceOver’s rotor.
1{
2  "commands": {
3    "_execute_action": {
4      "suggested_key": { "default": "Alt+Shift+R" },
5      "description": "__MSG_cmdOpenPopup__"
6    }
7  }
8}

Execution context: parsed at install. _execute_action is special: it opens the popup rather than firing commands.onCommand, which is exactly the entry point keyboard and screen-reader users need. The shortcut rules are in the execute action command and the four-shortcut limit.

2. Listen to the popup open

Open the popup with NVDA (Windows) or VoiceOver (macOS) running and listen before touching anything. On open you should hear the document’s title or the first heading, then where focus landed.

 1<!doctype html>
 2<html lang="en">
 3<head><title>Reader</title></head>
 4<body>
 5  <main>
 6    <h1 class="visually-hidden">Reader</h1>
 7    <p id="status" role="status">3 articles saved on this site.</p>
 8    <button id="save" aria-describedby="status">Save this page</button>
 9  </main>
10</body>
11</html>

Execution context: the popup document. lang on <html> sets the voice; a missing or wrong lang produces English phonetics for German text. A visually hidden <h1> gives screen-reader users an orientation point the visual design may not need, and role="status" makes the count available without moving focus. Localising lang itself is covered in localising extension UI with the i18n API.

3. Tab through every control and listen to each name

For every stop, the announcement should say what it is and what it will do. Icon buttons are the usual failure.

1<!-- Announced as "button" -->
2<button><span class="icon-trash" aria-hidden="true"></span></button>
3
4<!-- Announced as "Delete article, button" -->
5<button aria-label="Delete article"><span class="icon-trash" aria-hidden="true"></span></button>
6
7<!-- In a list: announced as "Delete 'How MV3 works', button" -->
8<button aria-label="Delete “How MV3 works”"></button>

Execution context: any extension page. The last form matters in lists: ten identical “Delete article” buttons are technically labelled and practically useless, because the user cannot tell which article each belongs to.

4. Trigger every dynamic change and listen for it

Saving, syncing, validation errors, a count changing — each should be announced without the user having to go looking.

1const status = document.querySelector("#status");
2
3async function save() {
4  status.textContent = "Saving…";
5  await chrome.runtime.sendMessage({ type: "page:save" });
6  status.textContent = "Saved. 4 articles on this site.";
7}

Execution context: the popup. Updating the text of an element that already has role="status" is what triggers the announcement — creating a new live region at the moment of the update often goes unheard. The live-region patterns are in announcing dynamic updates to screen readers.

5. Test injected UI inside a real page

A panel injected into a website competes with that website’s own landmarks and focus handling. Test it on two or three real, busy sites, not a blank fixture.

What to listen for: the panel is announced when it appears (without stealing focus unless the user invoked it); its controls are reachable; Escape closes it; and closing returns focus to where the user was.

1function openPanel(trigger) {
2  const previous = document.activeElement;
3  mountPanel();
4  root.querySelector("[data-autofocus]")?.focus();
5  onClose(() => (previous instanceof HTMLElement ? previous : trigger)?.focus());
6}

Execution context: the content script, inside the shadow root it created. Returning focus on close is the step most often missing, and without it a screen-reader user is dropped at the top of the page. The shadow-root mechanics are in injecting UI with shadow DOM without breaking the page.

6. Automate what does not need a human

1// e2e/a11y.spec.js — Playwright + axe against the built extension pages
2import AxeBuilder from "@axe-core/playwright";
3
4test("popup has no automatically detectable violations", async ({ page, extensionId }) => {
5  await page.goto(`chrome-extension://${extensionId}/popup.html`);
6  const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
7  expect(results.violations).toEqual([]);
8});

Execution context: Playwright with the extension loaded, as set up in loading an unpacked extension in Playwright. Loading popup.html as a tab is not identical to the popup surface — it is wider and never closes — but for markup-level checks it is exactly equivalent, and it runs on every commit.

An accessibility test pass for an extensionAutomated axe checks run on every build; a manual screen-reader pass covers opening by keyboard, naming, dynamic announcements and injected UI on real sites before each release.axe in CIevery commitPopup + options + panelloaded as tabsZero violationswcag2a / 2aabefore each releaseOpen by shortcut_execute_actionTab + listennames, order, updatesInjected UI on 3 real sitesfocus return
Automation keeps regressions out; the human pass finds the problems automation cannot describe.

Screen-reader basics for testers who do not use one

The manual pass is intimidating only until the first few minutes. A handful of commands cover everything above.

NVDA (Windows, free). Start with Ctrl+Alt+N. Tab/Shift+Tab move between controls; H jumps between headings in browse mode; Insert+F7 lists links, headings and landmarks; Insert+Q quits. Speech viewer (in the NVDA menu) shows everything spoken as text, which is invaluable for writing up what you heard.

VoiceOver (macOS, built in). Toggle with Cmd+F5. VO means Ctrl+Option. VO+Right reads the next item; Tab moves between controls; VO+U opens the rotor for headings and landmarks. The caption panel shows spoken text.

Two habits make the pass productive. Close your eyes, or turn the screen brightness down, for the first run through each surface — you will hear problems you would otherwise read past. And write down exactly what was spoken, not what you expected; “button, button, Save” is a finding, “the buttons are unclear” is not.

A fifteen-minute manual passTime allocation for a manual screen-reader pass across the popup, options page, dynamic updates and injected UI.0 min15 minOpen popup by …orientationTab through popupnamesOptions pageforms, errorsDynamic updatessave, sync, errorsInjected UI on real sit…focus returnmost markup issues already caught by axemost unique findings
Most of the findings come from the last two segments, which automation cannot cover.

Cross-browser variation

  • Chrome / Edge: the popup is exposed to assistive technology as a web document; NVDA and JAWS on Windows, VoiceOver on macOS all read it. _execute_action provides the keyboard entry point.
  • Firefox: accessibility tree exposure is equivalent. Firefox’s own accessibility inspector (DevTools → Accessibility) shows the computed name and role for any element, including in extension pages.
  • Safari: VoiceOver is the only relevant screen reader and is tightly integrated. Safari’s popup can behave differently with focus on open; test there specifically.
  • All three: axe-core results are engine-independent for markup checks; announcement behaviour varies by screen reader, which is why the manual pass should cover at least NVDA and VoiceOver.

Verification

  1. Run the axe suite in CI and confirm zero violations for every extension page.
  2. With NVDA’s speech viewer open, open the popup by shortcut and save the transcript. It should begin with the extension’s name or heading and a status line, not with “clickable, clickable”.
  3. Trigger a save and confirm the status change appears in the transcript without moving focus.
  4. Open the injected panel on a real site, press Escape, and confirm focus returns to the element that opened it:
1document.activeElement;   // should be the trigger, not <body>

Execution context: the page’s console after closing the panel. <body> means focus was dropped.

FAQ

Is testing with one screen reader enough?

It catches most problems. NVDA plus VoiceOver covers the majority of desktop users and the two most different implementations; add JAWS if enterprise users matter.

Should the popup move focus to the first control on open?

For a popup with one primary action, yes — it saves a keystroke. For one that opens to a status readout, leave focus on the document so the status is read first.

Does axe run inside the real popup surface?

Not directly; Playwright cannot attach to the popup surface itself. Loading the popup’s HTML as a tab is the standard substitute and is faithful for markup checks.

Other UI/UX Patterns & Interactive Components Resources