---
title: 'Screen Reader Experience'
source: 'https://academia.sh/en/courses/frontend-quality/screen-reader-experience'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
