Testing with Screen Readers: Core NVDA, VoiceOver and TalkBack Commands, Reading the Accessibility Tree
Screen reader users usually move through a page by lists of headings, landmarks and links, so testing starts with knowing browse mode versus focus mode and writing down the expected speech from the tree. We cover core NVDA, VoiceOver and TalkBack commands, the WebAIM 2026 survey, building element lists from Chromium's tree, adding a search landmark and book headings to Bookstore, and generating a test script for a manual session.
Version note: The examples in this post were run with WCAG 2.2, Angular 22.2 (TypeScript 6.0), Node.js 24, Playwright 1.65 (Chromium 158) and axe-core 4.14; the screen reader commands come from the documentation of NVDA 2026.3, macOS VoiceOver and TalkBack 9.1+.
Guess First
WebAIM's 11th Screen Reader User Survey, run in July and August 2026 with 1,780 respondents, asks this: when you look for information on a long page, what do you do first? What share of the answers say "I navigate by headings"? Keep your guess and move on to the second question: the catalog of Bookstore, the online bookstore used as the shared example across this site's series, has twenty books. How many headings appear in a screen reader's list of headings?
This is the series' post about testing with a real screen reader. Let's start with a warning: in the environment where I prepared this post, no NVDA, VoiceOver or TalkBack session could be run. The commands come from the official documentation; every output I show comes from Chromium's accessibility tree, and every "heard" column is "not run". The point of the post is to make that distinction clear: what can you know in advance from the tree, and what can you only hear with a screen reader?
How Does a Screen Reader Move Through a Page?
A sighted user looks at a page and jumps around with their eyes. A screen reader user gets the page as linear text and moves through it with keys. Windows screen readers have two modes for this. Browse mode (the virtual cursor in JAWS, Freedom Scientific's commercial screen reader) moves through a copy of the page taken from the browser: arrow keys read line by line, single letters jump between elements (H next heading, D next landmark, K next link). Focus mode (forms mode in JAWS) hands the keys to the page: while you type in a text box, the letter H does not jump to a heading, it goes into the box. According to the NVDA documentation, NVDA switches to focus mode by itself when you reach a text field with Tab or a click, NVDA+Space switches by hand, and Escape goes back to browse mode.
This has two consequences for developers. First, in browse mode the user's "position" and focus in the DOM are not always the same; the user may be reading a heading while focus is still on a button above it. A component that only listens to keydown gets no keys from a user in browse mode; the screen reader takes the key. This is where role matters in composite widgets: roles like listbox, tablist and grid tell the screen reader "switch to focus mode here and leave the arrow keys to the page". Second, users often do not read the page from top to bottom, they pick from lists: headings, landmarks, links, form fields. How meaningful those lists are directly decides how usable the page is.
VoiceOver on macOS has no such two modes; you move between elements with the VO keys (Control+Option or Caps Lock by default) and open lists from the rotor. On mobile there are touch gestures: in TalkBack, swiping right and left is the next and previous item and a double tap activates; iOS VoiceOver is similar.
A Minimum Command Set
The commands a developer has to memorize for testing are few. The table below comes from the NVDA 2026.3 User Guide (desktop keyboard layout; the NVDA key is Insert by default), Deque's VoiceOver shortcut list and Google's TalkBack 9.1+ gesture list:
| Task | NVDA (Windows) | VoiceOver (macOS) | TalkBack (Android) |
|---|---|---|---|
| Start / quit | Ctrl+Alt+N / NVDA+Q | Command+F5 | Settings, Accessibility, TalkBack, or holding both volume keys |
| Stop speech | Control | Control | Two-finger tap (pause) |
| Next / previous item | Down / Up Arrow (browse mode) | VO+Right / Left Arrow | Swipe right / left |
| Activate | Enter or Space | VO+Space | Double tap |
| Start reading | NVDA+Down Arrow | VO+A | From the TalkBack menu |
| Next heading | H (1-9 for a level) | VO+Command+H | Headings in the reading controls, then swipe down |
| Next landmark / link / form field | D / K / F (E edit field, B button, R radio) | Landmarks in the rotor / VO+Command+L / VO+Command+J | From the reading controls |
| Elements list | NVDA+F7 | VO+U (rotor) | Reading controls |
| Switch mode | NVDA+Space, Escape | No modes | No modes |
| Move in a table | Ctrl+Alt+Arrows | VO+Command+T to the table, then VO+Arrows | Explore by touch |
With Shift, the single-letter commands go backwards (Shift+H previous heading). In TalkBack you change the reading control with an up-then-down gesture (or a three-finger swipe down), and the TalkBack menu opens with a down-then-right gesture or a three-finger tap. To record what you hear in a session, NVDA's Speech Viewer in the Tools menu writes everything spoken as text into a window; it is the most practical way to copy it into test notes.
Which Screen Reader, Which Browser?
According to WebAIM's 2026 survey, the primary desktop screen reader is JAWS for 55.0%, NVDA for 32.9% and VoiceOver for 6.5%; for "the ones I use" it is JAWS 69.2%, NVDA 59.3%, VoiceOver 42.0% (70.3% of respondents use more than one screen reader). The most common combinations are JAWS + Chrome (31.2%), NVDA + Chrome (17.3%), JAWS + Edge (17.0%) and NVDA + Firefox (9.1%). On mobile, VoiceOver is at 72.2% and TalkBack at 29.5%. WebAIM itself writes that the sample was not controlled and may not represent all screen reader users; read these numbers as a rough answer to "what is common".
A practical choice for developers: NVDA + Chrome or Firefox on Windows (NVDA is free and uses the same mode model as JAWS), Safari + VoiceOver on macOS and iOS, Chrome + TalkBack on Android. JAWS is common in companies and public institutions; for Bookstore's Institutional Ordering customers (schools, libraries) it makes sense to try JAWS + Chrome at least once per release cycle. Do not assume that what works in one combination works in another: browsers pass the tree to platform APIs differently, and screen readers interpret it differently.
The answer to the survey guess: when looking for information on a long page, 67.8% of respondents navigate by headings first; then come in-page search (12.5%), reading the page (9.7%), links (6.7%) and landmarks (3.4%).
Element Lists from the Accessibility Tree
NVDA's Elements List and VoiceOver's rotor build lists from the accessibility tree the browser provides. We wrote a script that produces the same lists from Chromium's tree; it uses the Chrome DevTools Protocol's Accessibility.getFullAXTree command and walks the tree from the root to keep the page's reading order (the array the command returns is not in that order):
// Prints the lists a screen reader builds from the accessibility tree (like NVDA's Elements List or the
// VoiceOver rotor): landmarks, headings, links, buttons and form fields of one page.
// Usage: node elements.mjs <catalog|book|cart|statement>
import { chromium } from 'playwright';
import { open } from './lib.mjs';
const landmarkRoles = ['banner', 'navigation', 'main', 'contentinfo', 'search', 'region', 'form', 'complementary'];
const fieldRoles = ['textbox', 'searchbox', 'spinbutton', 'combobox', 'radio', 'checkbox', 'listbox'];
const name = process.argv[2] ?? 'catalog';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await open(page, name);
await page.waitForTimeout(300);
const cdp = await page.context().newCDPSession(page);
// The full tree from Chromium; "ignored" nodes (divs, spans without meaning) are not shown to a screen reader
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
// Walk the tree from the root so the lists keep the reading order of the page
const byId = new Map(nodes.map(n => [n.nodeId, n]));
const ordered = [];
const walk = n => { ordered.push(n); (n.childIds ?? []).forEach(id => byId.has(id) && walk(byId.get(id))); };
walk(nodes.find(n => !n.parentId));
const visible = ordered.filter(n => !n.ignored);
const prop = (n, key) => n.properties?.find(p => p.name === key)?.value.value;
const label = n => `"${n.name?.value ?? ''}"`;
const lists = {
landmarks: visible.filter(n => landmarkRoles.includes(n.role?.value)).map(n => `${n.role.value} ${label(n)}`),
headings: visible.filter(n => n.role?.value === 'heading').map(n => `h${prop(n, 'level')} ${label(n)}`),
links: visible.filter(n => n.role?.value === 'link').map(label),
buttons: visible.filter(n => n.role?.value === 'button').map(label),
'form fields': visible.filter(n => fieldRoles.includes(n.role?.value))
.map(n => `${n.role.value} ${label(n)}${prop(n, 'required') ? ' required' : ''}${prop(n, 'checked') === 'true' ? ' checked' : ''}`),
};
console.log(`${name}: "${await page.title()}"`);
for (const [list, items] of Object.entries(lists)) {
console.log(` ${list} (${items.length}): ${items.length > 6 ? [...items.slice(0, 5), `… ${items.at(-1)}`].join(', ') : items.join(', ')}`);
}
await browser.close();
node elements.mjs catalog
node elements.mjs book
node elements.mjs cart
catalog: "Bookstore"
landmarks (5): banner "", navigation "", main "", form "", contentinfo ""
headings (1): h1 "Catalog"
links (26): "Skip to main content", "Bookstore", "Catalog", " Cart (0)", "1984", … "Accessibility statement"
buttons (22): "Search", "Filters", "Add to cart: 1984", "Add to cart: Anna Karenina", "Add to cart: Brave New World", … "Add to cart: Wuthering Heights"
form fields (6): searchbox "Search books", radio "All" checked, radio "Classics", radio "Science Fiction", radio "Fantasy", radio "Dystopian"
book: "Dune · Bookstore"
landmarks (4): banner "", navigation "", main "", contentinfo ""
headings (1): h1 "Dune"
links (7): "Skip to main content", "Bookstore", "Catalog", " Cart (0)", " Back to catalog", … "Accessibility statement"
buttons (1): "Add to cart"
form fields (1): spinbutton "Quantity"
cart: "Cart · Bookstore"
landmarks (5): banner "", navigation "", main "", form "", contentinfo ""
headings (1): h1 "Your cart"
links (7): "Skip to main content", "Bookstore", "Catalog", " Cart (1)", "1984", … "Accessibility statement"
buttons (2): "Remove 1984", "Place order"
form fields (3): spinbutton "Quantity of 1984", textbox "Full name" required, textbox "E-mail" required
The answer to the second guess question: the catalog's list of headings has a single line, "Catalog". The twenty books are in the list of links by name, but not in the list screen reader users use most; a user who presses H jumps to the end of the page. The list of landmarks has no search box either. The form "" line comes from Chromium exposing an unnamed <form> with the form role; according to ARIA a form only counts as a landmark if it has a name, and screen readers like NVDA do not put an unnamed form in the list of landmarks. The leading space in link names (" Cart (0)") comes from the line break in the template; screen readers do not read the space.
Two Fixes: a search Landmark and Book Headings
We put each book's title in an h2 and wrapped the search form in a <search> element. The implicit role of <search> is search; it became Baseline newly available in October 2023 and widely available in April 2026, and it is covered in the "Semantic HTML and Landmarks" post of the HTML5 and Semantic HTML series:
<section class="toolbar">
<!-- <search> is the search landmark: "D" in NVDA and the rotor's Landmarks list find it -->
<search>
<form (submit)="applySearch($event, searchBox.value)">
<input #searchBox type="search" placeholder="Search by title, author or ISBN" aria-label="Search books" />
<button type="submit">Search</button>
</form>
</search>
<li class="book-card">
<!-- A heading per book: most screen reader users look for content by headings first -->
<h2 class="book-title"><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></h2>
catalog: "Bookstore"
landmarks (6): banner "", navigation "", main "", search "", form "", contentinfo ""
headings (21): h1 "Catalog", h2 "1984", h2 "Anna Karenina", h2 "Brave New World", h2 "Crime and Punishment", … h2 "Wuthering Heights"
links (26): "Skip to main content", "Bookstore", "Catalog", " Cart (0)", "1984", … "Accessibility statement"
buttons (22): "Search", "Filters", "Add to cart: 1984", "Add to cart: Anna Karenina", "Add to cart: Brave New World", … "Add to cart: Wuthering Heights"
form fields (6): searchbox "Search books", radio "All" checked, radio "Classics", radio "Science Fiction", radio "Fantasy", radio "Dystopian"
The list of headings now has 21 lines, and search appears in the list of landmarks. Because the card starts with an h2, the tree also shows that the book title is a heading:
- listitem:
- heading "1984" [level=2]:
- link "1984":
- /url: /books/9780451524935
- text: George Orwell · 1949 Dystopian
- strong: ₺279.00
- 'button "Add to cart: 1984"': Add to cart
Since we did not want the heading to look bigger, we gave .book-title the normal font size; remember that a heading is structure, not a visual size. The lists for the book page and the cart did not change: a single h1 and short content are enough there. node scan.mjs still reports zero violations on the four pages; axe did not count these two problems as violations, because neither is a direct failure in WCAG but a usability matter (close to 1.3.1 and 2.4.6).
A Bookstore Test Script and the Expected Speech
Manual screen reader testing is the test that takes the most time, so it helps to write down in advance what you will do each time and what you expect to hear. For Bookstore's main flow (search, filter, open the book, add to the cart, place the order) we wrote a small Playwright script that produces a test script. For each step it writes the keys to press in NVDA and VoiceOver and the information the tree gives at that step; it collects announcements with the recorder from the previous post (now a separate module, recorder.mjs):
// Writes the test script for a manual screen reader session: Bookstore's search -> filter -> book -> cart ->
// checkout flow, the keys to press in NVDA and VoiceOver, and what the accessibility tree says a screen reader
// should get at each step. The "heard" columns are filled in by hand during the session.
// Usage: node sr-script.mjs
import { chromium } from 'playwright';
import { base, focusedAx } from './lib.mjs';
import { recorder } from './recorder.mjs';
const browser = await chromium.launch();
const context = await browser.newContext();
await context.addInitScript(recorder);
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
const rows = [];
// A node with this role (and name) from Chromium's tree, as browse mode would reach it
async function nodeText(role, name) {
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const n = nodes.find(x => !x.ignored && x.role?.value === role && (name === undefined || x.name?.value === name));
if (!n) return `(no ${role}${name ? ` "${name}"` : ''} in the tree)`;
const level = n.properties?.find(p => p.name === 'level')?.value.value;
return `${role}${level ? ` level ${level}` : ''} "${n.name?.value ?? ''}"`;
}
// expected() runs after a short wait: focus can move a moment after the new page has rendered
async function step(task, nvda, voiceOver, expected = async () => '') {
await page.waitForTimeout(400);
const said = await page.evaluate(() => window.__announcements.splice(0));
rows.push([task, nvda, voiceOver, [await expected(), ...said].filter(Boolean).join(' + ')]);
}
await page.goto(base + '/');
await page.locator('.book-card').first().waitFor();
await step('open the catalog', 'page loads', 'page loads', async () => `title "${await page.title()}"`);
await step('jump to the search', 'D', 'VO+U, Landmarks', async () => await nodeText('search'));
const search = page.getByRole('searchbox', { name: 'Search books' });
await search.focus();
await step('go to the search field', 'E', 'VO+Command+J', async () => await focusedAx(page));
task | NVDA | VoiceOver | expected from the tree
open the catalog | page loads | page loads | title "Bookstore" + live polite: "20 books"
jump to the search | D | VO+U, Landmarks | (no search in the tree)
go to the search field | E | VO+Command+J | searchbox "Search books"
type "dune", Enter | type, Enter | type, Return | live polite: "1 book for "dune""
go to the categories | R | VO+Command+J | radio "All"
select Science Fiction | R R, Space | VO+Command+J x2, VO+Space | radio "Science Fiction" + live polite: "1 book in Science Fiction for "dune""
find the book | H | VO+Command+H | (no heading "Dune" in the tree)
open the book | Enter | VO+Space | heading "Dune", title "Dune · Bookstore"
add to the cart | B, Enter | Tab Tab, VO+Space | button "Add to cart" + ariaNotify normal: "Dune added to your cart. Cart: 1 item."
go to the cart | Tab, Enter | Tab, VO+Space | heading "Your cart", title "Cart · Bookstore"
go to the form | F until "Full name" | VO+Command+J until "Full name" | textbox "Full name"
fill in, Enter | type, Tab, type, Enter | type, Tab, type, Return | heading "Order #1 placed (Placed). Total: ₺489.00"
Before the fixes, two steps came up empty: "jump to the search" and "find the book". After the fixes:
jump to the search | D | VO+U, Landmarks | search ""
find the book | H | VO+Command+H | heading level 2 "Dune"
In a real session you use this table with two extra columns: "heard (NVDA + Chrome)" and "heard (VoiceOver + Safari)". You write the text you copy from Speech Viewer into these columns step by step. The session for this post was not run; both columns are empty. Every row where what the tree says differs from what you hear is a finding: either there is a problem in the application, or a mismatch between the browser and the screen reader. In the second case the thing to do is often to make the markup simpler.
Differences Between the Tree and the Speech
The tree is the information the screen reader gets; speech is an interpretation of that information. Knowing the differences shows where automated measurement ends.
The first is order and detail. NVDA may read a radio button in an order like "radio button not checked Classics", VoiceOver in another; verbosity settings change whether the role, the state or the description is read. The second is repetition. In the tree of the category group, the label text appears twice:
- group "Category":
- text: Category
- radio "All" [checked]
- text: All
- radio "Classics"
The radio's name and the label's text are both "Classics". Whether browse mode reads this once or twice can only be seen in a session; a note in the script saying "is there repetition in the category list?" is enough. The third is timing. A description connected with aria-describedby is read after a short pause in some screen readers; a polite message in a live region waits in the queue while the user reads something else. The tree does not show that timing. The fourth is what the tree does not show: as we saw in earlier posts, Playwright's aria snapshot does not write aria-current, and the CDP list skips some states too. Knowing which tool shows what is necessary so you do not read a missing line as "no problem".
In Chrome DevTools you get the same information from the Accessibility tab in the Elements panel; the tab's "Enable full-page accessibility tree" option makes the Elements panel show the tree instead of the DOM. The general use of this panel is in the "Introduction to Chrome DevTools and the Elements Panel" post of the Browser DevTools series. Firefox's Accessibility Inspector shows its own tree too; Firefox and WebKit were not used in this post.
In Production
Do manual screen reader testing not on every commit, but when a flow changes: a new page, a new composite widget, a change in the form flow. The rest of the time, scripts generated from the tree (like the one in this post) and automated scans catch regressions.
Write down and pin your test combinations. A line like "NVDA 2026.3 + Chrome, VoiceOver + Safari (macOS 27)" makes findings reproducible; behavior can change as screen reader and browser versions change.
Test with real users. A developer using a screen reader does not come close to the speed and habits of someone who uses one every day; expert users move much faster with keys, settings and guesswork. User testing also becomes the strongest line in the "how we checked" section of the accessibility statement.
Keep the accessibility statement up to date. Bookstore's statement says "Screen reader testing is planned"; after the first real session, the combination used and the date of the test belong there.
Common Pitfalls
First, assuming that screen reader users read the page from top to bottom. Most users go through the lists of headings, landmarks and links; if those lists are meaningless, the page becomes unusable.
Second, thinking every large text on screen is a heading, or using headings only for style. A heading describes structure; visual size is CSS's job.
Third, listening for arrow keys only with keydown in a composite widget without giving it a role. A screen reader in browse mode takes the keys and the component never gets them.
Fourth, testing with a single combination and generalizing the result. A live region that works in VoiceOver + Safari may behave differently in NVDA + Chrome.
Fifth, testing a screen reader only for "does it read". The real question is whether the task can be completed: can the user find the book and place the order?
A Small Experiment
★☆☆ Run elements.mjs for the statement page. Does the list of headings alone describe the structure of the statement? If you removed one heading, what information would be lost?
★★☆ Add the steps for removing a book from the cart to sr-script.mjs (Remove, dialog, Keep it / Remove). See where focus goes after the dialog in the script's output and compare it with the measurement in the series' keyboard post.
★★★ Install NVDA on Windows, open Bookstore in Chrome and fill in the table from sr-script.mjs with the text you copy from Speech Viewer. How many rows differ between what the tree says and what you hear? If you can, repeat the same flow with VoiceOver + Safari.
The filter panel, the disclosure and the radio group we wrote by hand work as components; but writing a combobox or a tabs pattern from scratch with the same care is long and error-prone. In the next post we look at the ready-made accessible components of Angular Aria, the CDK and Angular Material, and why you should not write your own combobox.