Lesson 11 / 27
Screen Reader Experience
A document's navigation planes and auditing plane defects, what an announcement carries, the polite and assertive priority of live regions, and the behavior of the announcement queue.
Contents
The previous lesson computed where focus goes. What the user learns once focus arrives there is a separate question, and its answer comes from two channels: the information that comes from the element itself, and the announcement of content that changes independently of the element.
This lesson’s subject is those two channels. The internal workings of screen readers and the exact words they speak vary by tool; what is covered here is what information is defined in the tree and which priority rules put announcements in a queue. These rules depend on the specification, not the tool.
A Page Is Not Read Linearly
A screen reader user does not listen to a page from beginning to end; that would be an unusable approach on a long document. The approach used is jumping between the document’s navigation planes: from landmarks to a section, from headings to a topic, from a link list to a destination, from a control list to a form.
Each plane is derived from the document, and defects in derivation make the plane unusable. The script below extracts three planes from the measurement list page and audits each one against its own criterion.
// nav-planes.mjs — deriving three navigation planes from a document and auditing defects const LANDMARK = { header: "banner", nav: "navigation", main: "main", aside: "complementary", footer: "contentinfo", section: "region", form: "form" }; // topLevel: is it a direct child of the document? const DOC = [ { element: "header", topLevel: true }, { element: "nav", topLevel: false, label: "Site-wide navigation" }, { element: "main", topLevel: true }, { element: "h1", text: "North Slope Measurement Station" }, { element: "form", topLevel: false, label: "Filter" }, { element: "a", text: "Detail", href: "/measurements/T-01" }, { element: "h3", text: "Daily Measurements" }, { element: "a", text: "Detail", href: "/measurements/T-02" }, { element: "a", text: "click here", href: "/method.html" }, { element: "h2", text: "Alerts" }, { element: "nav", topLevel: false }, { element: "a", text: "Measurement method document", href: "/method.html" }, { element: "footer", topLevel: true }, ]; const VAGUE = new Set(["click here", "click", "more", "read more", "link"]); function landmarks(doc) { const list = []; for (const d of doc) { let role = LANDMARK[d.element]; if (!role) continue; if ((d.element === "header" || d.element === "footer") && !d.topLevel) continue; if ((d.element === "section" || d.element === "form") && !d.label) continue; list.push({ role, label: d.label ?? null }); } return list; } const headings = (doc) => doc.filter((d) => /^h[1-6]$/.test(d.element)).map((d) => ({ level: Number(d.element[1]), text: d.text })); const links = (doc) => doc.filter((d) => d.element === "a"); console.log("landmark plane:"); for (const y of landmarks(DOC)) console.log(` ${y.role}${y.label ? ': "' + y.label + '"' : ""}`); console.log("\nheading plane:"); for (const b of headings(DOC)) console.log(` ${" ".repeat(b.level - 1)}h${b.level} ${b.text}`); console.log("\nlink plane:"); for (const b of links(DOC)) console.log(` "${b.text}" -> ${b.href}`); console.log("\ndefect audit:"); const findings = []; // 1. More than one unlabeled landmark of the same kind const counts = {}; for (const y of landmarks(DOC)) counts[y.role] = (counts[y.role] ?? 0) + 1; for (const y of landmarks(DOC)) { if (counts[y.role] > 1 && !y.label) findings.push(`unlabeled ${y.role} landmark, ${counts[y.role]} of the same role exist`); } // 2. Heading level skip let previous = 0; for (const b of headings(DOC)) { if (previous && b.level > previous + 1) findings.push(`heading level skipped: h${previous} -> h${b.level} ("${b.text}")`); previous = b.level; } // 3. Same text different href / different text same href / vague text const texts = {}; const hrefs = {}; for (const b of links(DOC)) { (texts[b.text] ??= new Set()).add(b.href); (hrefs[b.href] ??= new Set()).add(b.text); if (VAGUE.has(b.text.toLocaleLowerCase("en-US"))) findings.push(`non-descriptive link text: "${b.text}"`); } for (const [t, h] of Object.entries(texts)) { if (h.size > 1) findings.push(`text "${t}" goes to ${h.size} different targets`); } for (const [h, t] of Object.entries(hrefs)) { if (t.size > 1) findings.push(`target ${h} is linked with ${t.size} different texts`); } for (const b of findings) console.log(" - " + b); console.log(` ${findings.length} findings total`);
landmark plane:
banner
navigation: "Site-wide navigation"
main
form: "Filter"
navigation
contentinfo
heading plane:
h1 North Slope Measurement Station
h3 Daily Measurements
h2 Alerts
link plane:
"Detail" -> /measurements/T-01
"Detail" -> /measurements/T-02
"click here" -> /method.html
"Measurement method document" -> /method.html
defect audit:
- unlabeled navigation landmark, 2 of the same role exist
- heading level skipped: h1 -> h3 ("Daily Measurements")
- non-descriptive link text: "click here"
- text "Detail" goes to 2 different targets
- target /method.html is linked with 2 different texts
5 findings total
Each of the five findings breaks a different plane.
The second, unlabeled navigation block cannot be distinguished in the landmark list: the user sees two “navigation” entries and can only tell which one is the in-page navigation by opening both. The fix is about the name, not the structure.
The heading level skip breaks the outline. The output’s indentation shows the problem: an h3 following an h1 puts a topic above the h2 that follows it, when it should fall beneath it. The heading plane is the document’s table of contents; a skipped level produces a break that does not exist in the actual content.
Three defects appear together in the link plane. Text disconnected from context says nothing when read alone in a list. The same text going to two different destinations produces two entries in the list that cannot be told apart — the “Detail” link on every row of the measurement table is exactly this case, and the fix is adding the measurement code to the link text. Linking to the same destination with two different texts is the reverse defect: the user assumes there are two separate pages.
What an Announcement Carries
When focus arrives at an element, the information conveyed to the user comes from four
fields of that element in the tree: its role, its accessible name, its value,
and its states; to these is added the extra description linked through
aria-describedby. The order of delivery and the exact words vary by tool; the content
of the fields does not.
Two practical rules follow. First, the role’s name is not written into the name: a button named “Filter button” gets called a button twice, once by its name and once by its role. Second, the extra description is not appended to the name; it comes after the name — long help text is written into the description, not the name, or the control’s name turns into a paragraph.
Live Regions and Priority
Content that changes without the page reloading is a separate problem: if the user is not there, they cannot learn about the change. A live region is a section that is announced when its content changes, and it was set up for the error summary in the Web Fundamentals and HTML course.
A region has two priorities. Polite priority queues the announcement and does not
interrupt the user’s current reading; status messages belong here. Assertive priority
jumps ahead by overriding whatever is pending; only announcements that stop the user’s
work — data loss, a submission error — belong here. A role="status" declaration sets up
a polite region, and a role="alert" declaration sets up an assertive one.
The script below models this priority as a queue. The model does not show how long delivery takes; it shows what the priority in the specification does to the queue.
// announcements.mjs — queuing model for live-region announcements by polite/assertive priority const REGION = { "filter-status": { role: "status", priority: "polite" }, "submission-status": { role: "status", priority: "polite" }, "error-summary": { role: "alert", priority: "assertive" }, }; const DURATION = 2; // number of ticks an announcement occupies function run(title, events, lastTick) { console.log(title); console.log(" tick " + "incoming".padEnd(41) + "announced".padEnd(27) + "queue"); let queue = []; // pending polite announcements let active = null; // { text, remaining } let updated = 0, dropped = 0; for (let tick = 0; tick <= lastTick; tick++) { const incoming = events.filter((o) => o.tick === tick); let incomingText = []; for (const o of incoming) { const b = REGION[o.region]; incomingText.push(`${o.region}: ${o.text}`); if (b.priority === "assertive") { dropped += queue.length + (active ? 1 : 0); queue = []; active = { text: o.text, remaining: DURATION }; // interrupts an ongoing announcement } else { const i = queue.findIndex((k) => k.region === o.region); if (i >= 0) { queue[i] = { ...o }; updated++; } // the region's content was refreshed else queue.push({ ...o }); } } if (!active && queue.length > 0) active = { text: queue.shift().text, remaining: DURATION }; const announced = active && active.remaining === DURATION ? active.text : active ? "(ongoing)" : "—"; console.log( ` ${String(tick).padStart(2)} ${(incomingText.join(" | ") || "—").padEnd(41)}` + `${announced.padEnd(27)}${queue.length === 0 ? "—" : queue.map((k) => k.text).join(" | ")}`, ); if (active && --active.remaining === 0) active = null; } console.log(` queued announcements refreshed: ${updated}, dropped by an assertive announcement: ${dropped}`); } const EVERY_KEYSTROKE = [ { tick: 0, region: "filter-status", text: "12 measurements listed" }, { tick: 1, region: "filter-status", text: "5 measurements listed" }, { tick: 2, region: "filter-status", text: "3 measurements listed" }, { tick: 3, region: "submission-status", text: "Submitting record" }, { tick: 4, region: "error-summary", text: "Record submission failed" }, { tick: 7, region: "submission-status", text: "Record submitted" }, ]; const DEBOUNCED = [ { tick: 2, region: "filter-status", text: "3 measurements listed" }, { tick: 3, region: "submission-status", text: "Submitting record" }, { tick: 6, region: "error-summary", text: "Record submission failed" }, { tick: 9, region: "submission-status", text: "Record submitted" }, ]; run("announcement on every keystroke:", EVERY_KEYSTROKE, 9); console.log(""); run("single announcement with debounced triggering:", DEBOUNCED, 11);
announcement on every keystroke: tick incoming announced queue 0 filter-status: 12 measurements listed 12 measurements listed — 1 filter-status: 5 measurements listed (ongoing) 5 measurements listed 2 filter-status: 3 measurements listed 3 measurements listed — 3 submission-status: Submitting record (ongoing) Submitting record 4 error-summary: Record submission failed Record submission failed — 5 — (ongoing) — 6 — — — 7 submission-status: Record submitted Record submitted — 8 — (ongoing) — 9 — — — queued announcements refreshed: 1, dropped by an assertive announcement: 1 single announcement with debounced triggering: tick incoming announced queue 0 — — — 1 — — — 2 filter-status: 3 measurements listed 3 measurements listed — 3 submission-status: Submitting record (ongoing) Submitting record 4 — Submitting record — 5 — (ongoing) — 6 error-summary: Record submission failed Record submission failed — 7 — (ongoing) — 8 — — — 9 submission-status: Record submitted Record submitted — 10 — (ongoing) — 11 — — — queued announcements refreshed: 0, dropped by an assertive announcement: 0
The first run produces two losses. Because every character typed into the filter field triggers an announcement, the pending count announcement is refreshed once: while the user is listening to the “12” announcement, the count drops to five and then to three, and the first announced count is already stale. The second loss is worse: when the assertive error notification arrives, the pending “Submitting record” announcement is dropped. The user hears that submission failed without ever hearing that it started.
The second run applies both fixes together. The filter announcement is triggered once after typing stops, not on every keystroke; this is the accessibility counterpart of the debounced triggering from the Browser and the Web Platform course. The error notification also lands after the queue has drained. The loss count drops to zero.
Three rules follow. Text written to a polite region must announce the final state, not intermediate states. Because assertive priority drops whatever is pending, it is used only for announcements that stop the user’s work. If a region updates frequently, the announcement is written after things settle, not after every update.
Setting Up the Region
A live region must be present in the document beforehand; the content of a region added afterward may not be announced. An empty container is placed when the page is set up, and its content is filled in later.
The region’s content must be a short, status-reporting sentence. Turning the table itself
into a live region — that is, marking the table itself with an aria-live declaration —
leads to the entire content being announced on every row change. The correct setup is
keeping a status region outside the table and writing “3 measurements listed” into it.
The table itself stays silent; after hearing the count, the user navigates to the table
on their own.
Criterion 4.1.3 turns this into a requirement: status messages presented without a change of focus must be programmatically determinable and must be announced without moving focus. The criterion has two sides — the message must be announced, but focus must not be stolen to announce it. Code that moves focus to the table after a filter result fails the criterion from the opposite direction.
Summary
- A screen reader user does not read a document linearly; they jump between landmark, heading, and link planes, and each plane is derived from the document.
- Plane defects are countable: an unlabeled duplicate landmark, a skipped heading level, link text disconnected from context, the same text to different targets, and different text to the same target.
- The information announced when focus arrives at an element comes from role, name, value, state, and extra description; the role’s name is not written into the name, and long text goes into the description, not the name.
- Polite priority queues an announcement, assertive priority jumps ahead by dropping whatever is pending; assertive is only for announcements that stop the user’s work.
- In a frequently updated region, the announcement is written after things settle, not on every change, and it announces the final state.
- A live region must already exist in the document, its content must be short, and the announcement must not move focus.
Next Step
This lesson established how information is conveyed through non-visual channels. The visual-channel counterpart of the same information is tied to a measurable threshold: whether text and component boundaries on screen can be distinguished from their surroundings. The next lesson treats contrast as a computable quantity — deriving the contrast ratio from relative luminance, the different thresholds for text and non-text elements, and auditing every state of the filter panel against those thresholds.
To keep your progress and take notes, Log in
My notes
Log in to take notes.