cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

Screen readers and live regions

How screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.

The page that says nothing

Picture a store with a search box at the top. You type running shoes, and after a short pause the list below fills with 24 products and a line that says "24 results". A sighted user glances down and sees it at once. Nothing about this looks broken.

Now picture Maya, who is blind and uses a screen reader. She types the same words, and her focus stays in the search box. The results arrive elsewhere on the page, and nothing tells her: she hears her own keystrokes, then silence. To learn whether the search ran, failed, or found nothing, she has to leave the box and hunt through the page.

A screen reader can only speak what the page tells it, and most pages tell it far less than they show. Two things fix most of it: giving every control a clear accessible name, and announcing changes that happen away from focus with live regions.

Two views of the same search. On the left, what a sighted user sees: the search box with running shoes typed in, a line saying 24 results, and a grid of product cards. On the right, what a screen reader user hears: Search products, edit text, then the echo of each typed letter, then silence, because nothing tells the screen reader that results arrived.What you seerunning shoes24 resultsseen at a glanceWhat a screen reader saysSearch products, edit textr, u, n, n, i, n, gs, h, o, e, s(results arrive)... silence
The page changed far from where focus is. Without a live region, the screen reader has no reason to say anything, so the result that every sighted user sees is never heard.

What a screen reader is

A screen reader is software that turns what is on screen into speech or braille. It does not look at pixels. It asks the browser for a structured description of the page, and reads that out one piece at a time: "Search, edit text", "Add to cart, button", "Remember me, checkbox, not checked".

Blind users rely on it for everything, and many people with low vision or reading difficulties use it some of the time. Experienced users often set the speech rate far faster than normal talk, so clutter and repetition cost them real time on every visit.

The screen readers you will meet

Five matter for web work. Each is tied to an operating system and usually paired with a particular browser, because it reads the page through that browser.

The common screen readers and the browser each is usually tested with.
Screen readerPlatformCostUsual browser
JAWSWindowsPaid licenceChrome, Edge
NVDAWindowsFree and open sourceChrome, Firefox, Edge
VoiceOvermacOS, iOS, iPadOSBuilt inSafari
TalkBackAndroidBuilt inChrome
NarratorWindowsBuilt inEdge

WebAIM, a nonprofit at Utah State University, surveys screen reader users every few years. In its tenth survey (December 2023 to January 2024, 1,539 responses), the primary desktop reader was JAWS for 40.5% of respondents, NVDA for 37.7%, and VoiceOver for 9.7%. On mobile, 70.6% commonly used VoiceOver and 34.7% TalkBack. The survey leans towards experienced users, so treat it as a guide, not a census.

The mental model: a second tree

Here is the one picture to keep in your head. The browser builds two trees from your HTML. The DOM is the one you know. Next to it, the browser builds the accessibility tree: a slimmer copy that keeps only what matters to assistive technology, and describes each piece in a fixed vocabulary.

Each node in the accessibility tree has four things a screen reader cares about:

  • Role: what it is. button, link, checkbox, heading, textbox, navigation. It tells the user what they can do with it.
  • Name: what it is called. "Save", "Search", "Close dialog". It is how the user tells this button apart from the others.
  • State and properties: how it is right now. Checked, expanded, disabled, selected, invalid, heading level 2.
  • Value and description: the text in a field or the position of a slider, plus any extra help text.

A screen reader speaks those parts in a set order, usually name, then role, then state: "Remember me, checkbox, not checked". If the name is missing, the user hears "button" and nothing else. If the role is missing, they hear "Save" and have no idea that it can be pressed.

From DOM to speech

The screen reader never reads your HTML directly. The chain has four layers, and a bug at any layer means silence or nonsense at the end.

  1. The DOM and CSS. Your elements, attributes, ARIA, and styles. display: none and hidden remove things here.
  2. The browser's accessibility tree. The browser maps each element to a role, computes its name, and drops what does not matter, such as a plain div used only for layout.
  3. The platform accessibility API. The browser exposes the tree through the operating system's API: UI Automation and IAccessible2 on Windows, the macOS and iOS accessibility APIs on Apple systems, and the Android accessibility framework on Android.
  4. The screen reader. It reads the tree through that API, listens for change events, and turns nodes into speech or braille, with its own rules for what to say and when.
Four layers from markup to speech. Inside the browser: the DOM and CSS, your elements and ARIA, feed the accessibility tree, which holds a role, name, and state for each node. The browser exposes the tree through the operating system's accessibility API, such as UI Automation and IAccessible2 on Windows, or the macOS and iOS accessibility APIs. The screen reader reads that API and turns nodes into speech or braille.BrowserDOM and CSSelements, attributes, ARIAAccessibility treerole, name, state per nodeOperating systemAccessibility APIUIA, IA2, AX API, AndroidAssistive technologyScreen readerspeech or braille
The screen reader is two layers away from your HTML. Whatever the accessibility tree says is all it can know, and both the browser mapping and the screen reader's own rules can change what the user finally hears.

This layering explains why the same page can sound different in NVDA and VoiceOver: each screen reader has its own speaking rules on top of the tree. Browsers differ too, mapping the same ARIA slightly differently. That is why testing pairs a screen reader with a browser.

What the tree keeps and what it drops

Take a small product card: a div wrapper, an image, a heading, a price in a span, and a button that shows only a cart icon. The DOM has seven elements. The accessibility tree keeps the image (with its alt as the name), the heading (role heading, level 3), the price as plain text, and the button. The layout wrapper and the decorative SVG inside the button disappear.

On the left, the DOM of a product card: main, then a div with class card, holding an img with alt text, an h3, a span with the price 89 dollars, and a button that holds only an SVG icon marked aria-hidden. On the right, the accessibility tree built from it: main, then directly an image named Blue runner, a level 3 heading named Blue runner, the text 89 dollars, and a button with no name. The layout div is dropped and the hidden SVG is removed.DOMmaindiv.cardimg alth3span $89buttonsvg hiddenaccessibility treemain(div dropped)image “Blue runner”heading 3 “Blue runner”text “$89”button “” no name(svg removed)
The tree drops layout wrappers and anything with aria-hidden. What remains is what the user can reach. The icon button survives with a role but an empty name, so it is announced as just “button”.

Notice the button in the figure. It holds only an SVG icon with aria-hidden="true", so it has no text, and the user hears just "button". This is one of the most common bugs on the web. The 2026 WebAIM Million, a yearly automated scan of the top one million home pages, found empty links on 46.3% of them and empty buttons on 30.6%.

Check your understanding · Exercise 1

A close button contains only an SVG X icon marked aria-hidden="true". What does a screen reader announce when the user tabs to it? Pick one.

How people actually read a page

Sighted users scan the layout and jump to what they want. Screen reader users scan too, with keys instead of eyes, and knowing how they move tells you which markup matters.

Browse mode: the virtual cursor

On Windows, NVDA and JAWS read web pages in browse mode (JAWS calls it the virtual cursor). The screen reader keeps its own copy of the page as a flat document and moves a reading cursor through it. The arrow keys move that cursor line by line or character by character. The page itself never sees those keys.

Browse mode also turns single letters into quick keys that jump by element type. This is how most users get around, and it is why semantic HTML matters so much: if your headings are styled divs, the H key finds nothing.

Common browse mode keys. Adding Shift goes backwards. NVDA is the modifier key, Insert by default.
Key (NVDA and JAWS)Jumps toNeeds in your markup
H, or 1 to 6Next heading, or next heading of that levelReal h1 to h6 in a sensible order
D (NVDA) or R (JAWS)Next landmarkmain, nav, header, footer, labelled regions
K (NVDA), or TabNext link, or next focusable elementReal a href links and buttons
FNext form fieldReal inputs with labels
BNext buttonReal button elements
TNext tableData tables with headers
NVDA+F7, or Insert+F6 and Insert+F7 (JAWS)A list of links, headings, or landmarks (JAWS: F6 headings, F7 links)Names that make sense out of context

That last row explains a classic rule. In a list of every link on the page, ten links called "Read more" are useless. "Read more about the spring sale" is not.

Focus mode: keys go to the page

Browse mode would make typing impossible: pressing F in a search box would jump to the next field. So when focus lands in a text field, a select, or a widget that needs arrow keys, the screen reader switches to focus mode (JAWS calls it forms mode). Now keys go straight to the page, as they would without a screen reader. By default NVDA plays a short sound on each switch.

The switch is mostly automatic, driven by roles. A textbox, combobox, listbox, grid, or slider usually triggers focus mode. A button or link does not, because Enter and Space work in both modes. Users can also switch by hand: NVDA+Space in NVDA; in JAWS, Enter on a field turns on forms mode, and Insert+Z turns the virtual PC cursor off or on.

A state machine with two states. The page loads in browse mode, where arrow keys move the screen reader's own cursor and letters are quick keys: H for headings, K for links, F for fields, D for landmarks. When focus lands in a text field or a widget such as a listbox or grid, the screen reader switches to focus mode, where typing and arrow keys go to the page. Escape, or moving focus to a link or plain button, returns to browse mode. NVDA plus Space toggles by hand.page loadsBrowse modereader owns the keysFocus modepage owns the keysfocus enters a field or widgetEscape, or Tab to a linkH heading, K linkF field, D landmarkarrows read linestyping goes to inputarrows go to widgetEscape goes backNVDA+Space toggles by hand
Roles decide the switch. A widget with the right role gets focus mode and receives its arrow keys; a role-less div stays in browse mode, and the screen reader keeps the arrows for itself.

This matters for your widgets. Give a custom tab list or menu the right role, and the screen reader enters focus mode so your arrow key handlers run. Give it no role, and the screen reader stays in browse mode, keeps the arrow keys, and your widget seems dead. The deep-aria-patterns lesson covers which roles each widget needs.

VoiceOver and touch screens

VoiceOver on the Mac has no separate browse mode. It moves through the page with VO plus arrow keys (VO is Control+Option) and offers the rotor, VO+U, a menu for jumping by headings, links, landmarks, or form controls. Typing in a field works as normal.

On phones, VoiceOver and TalkBack work by touch. Swiping right moves to the next item, swiping left to the previous one, and a double tap activates. Dragging a finger over the screen reads whatever is under it. The rotor on iOS (a two-finger twist) and the reading controls on Android let users jump by headings or links, the same idea as quick keys.

Across all of them, one rule holds: reading position and focus are not the same thing. A screen reader user can be reading the price of a product while keyboard focus is still in the search box. That gap is exactly why live regions exist: a change can happen far from where the user is reading, and something has to tell them.

Check your understanding · Exercise 2

You built a carousel with left and right arrow keys on a plain div that has tabindex="0" and no role. NVDA users say the arrows do nothing. Why? Pick one.

Accessible names and descriptions

Every interactive element needs a name, and the browser works it out with a fixed algorithm called accessible name computation, defined in the W3C spec "Accessible Name and Description Computation" (accname for short). You do not set the name directly. You give the browser sources, and it picks one in a fixed order. Knowing that order is how you debug "why does it say that?".

The order the browser checks

For a typical control, the browser walks this list and stops at the first source that gives non-empty text:

  1. aria-labelledby: the text of the elements whose ids it lists, joined with spaces. It wins over everything, and it can even point at hidden elements.
  2. aria-label: a string you write directly on the element.
  3. The native label from HTML: a label element tied to an input, alt on an image, legend for a fieldset, caption for a table, the title element inside an SVG.
  4. The element's own content: only for roles that allow it, such as button, link, heading, tab, option, menuitem, and table cells. The text inside button becomes its name.
  5. title: the tooltip attribute, used only as a last resort.
  6. placeholder: for text inputs, used only when nothing else exists.
A flowchart of the accessible name computation. Is aria-labelledby set? If yes, the name is the text of the referenced elements. If no, is aria-label set? If yes, use that string. If no, is there a native label, alt, or legend? If yes, use it. If no, does the role take its name from content, like a button or link? If yes, use the text inside. If no, is there a title or placeholder? If yes, use that text, a weak fallback. If no, the element has no name, and a button is read as just button.aria-labelledby set?yestext of those idsnoaria-label set?yesthat stringnolabel, alt, or legend?yesthe native labelnorole named by content?yesthe text insidenotitle or placeholder?yesthat text (weak)nono name: just “button”
The first yes wins and everything below it is ignored. Containers such as div, nav, and dialog skip the content step, so they need aria-labelledby or aria-label.

Two details catch people. First, step 4 does not apply to most containers: a div, section, nav, ul, or dialog never gets its name from its content, so a dialog needs aria-labelledby pointing at its heading.

Second, higher sources hide lower ones completely. If a button has aria-label="Close" and visible text "Cancel", the screen reader says "Close" and the visible "Cancel" is ignored.

Give the button:
<button><svg aria-hidden="true"></button>
The four name sources in the order the browser checks them. aria-labelledby: not set. aria-label: not set. visible text: not set. title: not set. The screen reader says: button.1aria-labelledbynot set2aria-labelnot set3visible textnot set4titlenot setheard: “button”
Sources are checked top to bottom. The first one that is set becomes the name, and every source below it is ignored, even if it is visible on screen.

Problem: No name at all. The user hears only “button” and has to guess what it does.

Picking the right technique

The order above is about which source wins, not which one to prefer. Visible native labels are the safest choice: they are seen by everyone, translated by the page's translation tools, and clicked to focus the field. Reach for ARIA naming only when there is no visible text to use.

html
<!-- 1. Best: a visible label tied to the input -->
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email">

<!-- 2. Name from content: the text inside is the name -->
<button type="submit">Save changes</button>

<!-- 3. Icon-only button: no visible text, so aria-label -->
<button type="button" aria-label="Close">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

<!-- 4. Name from visible text elsewhere: aria-labelledby -->
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title">
  <h2 id="dlg-title">Delete project?</h2>
</div>

<!-- 5. Several pieces of visible text joined -->
<button id="rm-3" aria-labelledby="rm-3 item-3">Remove</button>
<span id="item-3">Blue running shoes</span>
Five ways to name things, from the safest to the most specialised. Example 5 reads as "Remove Blue running shoes".

Example 5 solves a common problem: in a list of 20 items, 20 buttons named "Remove" are useless in a screen reader's list of buttons. Joining the button's own text with the item name gives each a unique name while the screen still shows one short word.

Keep the visible words in the name

WCAG success criterion 2.5.3, Label in Name, says that when a control has visible text, its accessible name must contain that text, ideally at the start. This is mostly for voice control users, who say "click Send" to press a button labelled Send. If its aria-label is "Submit message", the command fails. A good rule: if you can see words on a control, never override them with different words.

Descriptions: the extra sentence

The name should be short: a word or a few. Anything longer, such as format hints, password rules, or an error message, belongs in the accessible description. The browser computes it from aria-describedby, which points at one or more ids just like aria-labelledby. If there is no description and title was not used for the name, title becomes the description.

html
<label for="pw">Password</label>
<input id="pw" type="password"
       aria-describedby="pw-rules pw-error" aria-invalid="true">
<p id="pw-rules">At least 12 characters.</p>
<p id="pw-error">Too short: 8 characters.</p>
Heard on focus as: "Password, secure edit text, invalid entry, At least 12 characters. Too short: 8 characters." The exact words vary by screen reader.

Screen readers read the description after the name, role, and state, often after a short pause, and users can turn descriptions off. So never put something essential only in the description. The newer aria-description attribute, from the ARIA 1.3 draft, takes a string directly; support is still less even than for aria-describedby, so prefer pointing at visible text.

Check your understanding · Exercise 3

An input has aria-label="Search products", a label element saying "Search", and placeholder="Try running shoes". What name does a screen reader announce? Pick one.

Live regions: speaking without moving focus

Back to Maya in the search box. Her focus is in the input and must stay there, so she can keep typing. We need the screen reader to say "24 results" without moving her anywhere. That is what a live region does: it marks a part of the page whose changes should be spoken as they happen, wherever the user is.

html
<input type="search" aria-label="Search products" aria-controls="results">

<!-- On the page from the start, empty -->
<p id="result-count" role="status"></p>

<script>
  // Later, after the fetch resolves:
  document.getElementById("result-count").textContent = "24 results";
</script>
The smallest working live region: an empty element with role="status", filled later.

What happens when the text changes

When your code changes the text inside the region, the browser notices the change in the DOM, updates the accessibility tree, and fires a platform event that says "this live region changed, here is the new text". The screen reader receives the event and decides when to speak it, based on how urgent the region says it is.

A sequence diagram with four columns: your code, the browser, the screen reader, and the user. Your code sets the text of a status region to 24 results. The browser updates the accessibility tree and sends a live region changed event to the screen reader. The screen reader is still speaking the echo of the typed letters s, h, o, e, s, so it finishes that first. Then it speaks 24 results to the user.Your codeBrowserScreen readerUserset text: 24 resultslive region changedfinishes the echo24 resultspolite: waits its turn in the queue
Your code only changes text. The browser turns that into an event, and the screen reader decides when to speak it. With polite, the message waits for the current speech to end.

Note the queue in the figure. A screen reader is always speaking or about to speak something: the letter just typed, the name of a field, a line of text. A live region message joins that stream. How it joins is what aria-live controls.

Polite, assertive, and off

  • aria-live="polite": wait until the screen reader is done with what it is saying and the user pauses, then speak. Use this for almost everything: result counts, "Saved", "Item added to cart", progress.
  • aria-live="assertive": speak as soon as possible, and many screen readers cut off the current speech to do it. Use it only when the user must know right now: "Your session expires in 1 minute", "Payment failed", "Connection lost".
  • aria-live="off": the default. Changes are not announced, though users can still read the region by moving to it.

Assertive messages are not just louder; they can destroy other information. If an assertive message fires while the screen reader is reading the name of the field the user just tabbed to, the user may never hear that name. Two assertive messages in quick succession can cancel each other, and only the last is heard.

Roles with live behaviour built in

Some ARIA roles are live regions with sensible defaults, so you rarely need to write aria-live by hand.

Live region roles from WAI-ARIA 1.2. An explicit aria-live overrides the implicit value.
RoleImplicit politenessImplicit atomicUse for
statuspolitetrueResult counts, save confirmations, progress text. The HTML output element has this role.
alertassertivetrueUrgent, time-sensitive errors and warnings
logpolitefalseChat messages, activity feeds, console output: new lines appended in order
timerofffalseA visible countdown that should not speak every second
marqueeofffalseTickers and scrolling news that change often

Decision Which live region should a message use?

role="status"

An element with role="status", present from page load, text replaced on each update.

Pros

  • Polite by default, so it never interrupts
  • Reads the whole message each time (atomic)
  • Clear meaning for code reviewers

Cons

  • Can be delayed for a second or more behind other speech
  • Not suited to a stream of separate messages

role="alert"

An element with role="alert"; the error text is placed in it when the error happens.

Pros

  • Spoken at once
  • Some screen readers also play a sound or show it in braille as an alert

Cons

  • Interrupts the user, and can swallow other speech
  • Overuse trains users to ignore it

aria-live="polite" on a container

Any element with aria-live="polite" and your choice of aria-atomic.

Pros

  • Full control: you can read only the changed part (atomic false)
  • Fits regions that also have another role

Cons

  • Easy to get the settings wrong
  • No semantic meaning by itself

role="log"

A list where each new message is appended as a new child.

Pros

  • Each new entry is read in order
  • Old entries are not repeated

Cons

  • Long histories need trimming
  • Busy chats can flood the user; let them mute it

What we pick: Use role="status" for nearly all feedback, role="alert" only for errors that need action now, and role="log" for chat-like streams. Reach for raw aria-live only when a role does not fit.

aria-atomic, aria-relevant, and aria-busy

aria-atomic decides how much is read. With true, the whole region is read whenever anything in it changes; with false (the default for plain aria-live), only the changed nodes are read. Picture a region holding "Cart: 3 items, total $42" where only the number changes. With false the user may hear just "3"; with true they hear the full sentence.

aria-relevant says which kinds of change count: additions, removals, text, or all. The default, additions and text, is almost always right, and support for removals is poor, so leave it alone. aria-busy="true" asks the screen reader to hold back announcements while you make several changes, until you set it back to false. Support varies, so do not depend on it.

The rule people break most: the region must already exist

Screen readers and browsers track live regions that are already in the accessibility tree. They announce changes to a region. If you create a new element that already has aria-live and its text in a single step, there is no change to report: the region was born with that text. In many browser and screen reader pairs, that message is never spoken.

Two columns. On the left, the broken way: at first there is no region at all, then a status region appears already holding 24 results. Nothing changed inside a watched region, so many screen readers stay silent. On the right, the working way: an empty status region is in the page from load, then its text changes to 24 results, which is a change the screen reader is watching, so it is spoken.Born with its textat load(no region)results arrivestatus: 24 resultsno change seen: often silentExists first, then changesat loadstatus: (empty)results arrivestatus: 24 resultstext changed: spoken
Screen readers announce changes to regions they already know about. Mount the region empty, early, and change only its text.

The same trap appears in frameworks. A React component that renders {error && <p role="alert">{error}</p>} mounts the region and its text together. Some pairs announce it, because alert gets special handling, but many do not announce a status region mounted this way. The reliable pattern is to render the region always, empty, and change only its text.

Check your understanding · Exercise 4

Your toast component creates a new div role="status" with the message text, appends it to body, and removes it after 5 seconds. Testers say NVDA often stays silent. What is the most reliable fix? Pick one.

Announcing async results

Most modern UIs change without a page load: results arrive, saves finish, items get added, errors come back from the server. Each of these is a moment when a sighted user sees something change and a screen reader user hears nothing unless you plan for it. The design question for each one is: announce, move focus, or stay quiet?

Choosing between an announcement and a focus move for common async events.
What happenedBest responseWhy
Search or filter results updatedPolite announcement: "24 results for running shoes"The user is still typing or choosing; focus must stay put
Form saved in the backgroundPolite: "Changes saved"Confirms the action without interrupting
Item added to cartPolite: "Added Blue running shoes. Cart has 3 items"The button stays focused so the user can carry on
Form submit failed validationMove focus to an error summary or the first invalid fieldThe user must act, and focus takes them there
Route change in a single-page appMove focus to the new page headingLike a page load; see the deep-focus-management lesson
Session about to expireAssertive, plus a dialog with focusUrgent and needs a decision
A list item deletedMove focus to a neighbour, then a polite "Deleted"Focus would be lost with the removed element
Typing indicator, live cursor movesUsually nothingFrequent and low value; it would drown everything else

The pattern behind the table: if the user must do something next, move focus there. If they only need to know, announce politely. If it changes constantly and matters little, stay quiet and let them read it when they want.

Search as you type

Live search is the hardest case, because results change on nearly every keystroke. If you announce each update, the user typing shoes hears "312 results", "88 results", "40 results", "26 results", "24 results" fighting with the echo of their own keys. Polite messages queue up, so they may still be listening to old counts long after they stopped typing.

The fix is to announce only when results settle. Debounce the request as you would anyway (around 300 ms), and then announce only the result of the latest request, after a further short pause of about 500 ms to 1 second with no typing. Ignore results from stale requests entirely, as the deep-request-races lesson explains for the data itself.

A timeline over 3 seconds. The user types the five letters of shoes between 0 and 1.2 seconds. In the top row, announcing every response, the screen reader queues 312 results, 88, 40, and 26, and is still reading stale counts nearly 3 seconds in. In the bottom row, announcing once results settle, the screen reader stays quiet while the user types and says 24 results for shoes once, starting at about 2 seconds.you typeshoesevery update312884026stale counts, still queuedwhen settled24 results for shoes0s1s2s3s
Announce the latest result once typing has paused for about a second, not every response. The user hears one correct sentence instead of a queue of stale numbers.

Make the message useful on its own. "24 results for running shoes" is better than "24" or "Results updated", because the user may hear it a second later, mixed with other speech. For zero results, say so plainly: "No results for running shoez". While a slow request is in flight, a polite "Searching" after about a second helps, but skip it for fast responses or it becomes noise.

Where the count goes:
Politeness:
When to announce:
An 8 second timeline. The user types shoes, one key every 0.3 seconds, and presses Tab to a Sort by control at 2 seconds. Search responses arrive 0.7 seconds after each key. The screen reader says, in order: s, h, o, 312 results for s (cut off), e, 88 results for sh (cut off), s, 40 results for sho (cut off), Sort by, combo box, 26 results for shoe, 24 results for shoes.you dotypes “shoes”Tabpagemessage sent to the regionyou hearSort by, combo box26 results for shoe24 results for shoes3 messages cut off0s2s4s6s8s
Early counts are cut off by the next keystroke, and the rest queue up behind the Tab announcement. The user hears a stale count after they stop typing, and the right number comes last. Announce only the latest, settled result.

Heard, in order: s, h, o, 312 results for s (cut off), e, 88 results for sh (cut off), s, 40 results for sho (cut off), Sort by, combo box, 26 results for shoe, 24 results for shoes.

    Sent to the region: (nothing yet)

    One announcer for the whole app

    Scattering live regions through components leads to the bugs from the last chapter: regions mounted with their text, two regions speaking at once, messages lost when a component unmounts. Most design systems instead render two visually hidden regions once, at the app root, and expose one function to fill them.

    tsx
    const announceRef = { current: (_text: string, _urgent?: boolean) => {} };
    export const announce = (text: string, urgent = false) => announceRef.current(text, urgent);
    
    // Rendered once, near the root, before any message is sent.
    export function Announcer() {
      const [polite, setPolite] = useState("");
      const [assertive, setAssertive] = useState("");
      useEffect(() => {
        announceRef.current = (text, urgent = false) => {
          const set = urgent ? setAssertive : setPolite;
          set("");                              // clear first, so a repeated
          setTimeout(() => set(text), 100);     // message still counts as a change
        };
      }, []);
      return (
        <>
          <div role="status" className="sr-only">{polite}</div>
          <div role="alert" className="sr-only">{assertive}</div>
        </>
      );
    }
    
    // Anywhere in the app:
    announce("Changes saved");
    announce("Payment failed. Check your card details.", true);
    
    A minimal announcer. React Aria's announce, Angular CDK's LiveAnnouncer, and many design systems ship this same idea.

    Note the clear-then-set step. If the user saves twice, setting "Changes saved" again is not a change, and the second save is silent. Clearing the region and setting the text a moment later makes it a real change.

    Toasts and notifications

    Toasts are the most common source of noise and silence at the same time. A few rules make them work:

    • Send the toast text through the shared polite region (or the alert region for errors), not through a freshly mounted element.
    • Do not put actions such as "Undo" only in a toast that disappears after 4 seconds; a screen reader user may not find the button before it vanishes. Keep actions available elsewhere, or let the toast stay until dismissed (WCAG 2.2.1, Timing Adjustable).
    • Never move focus into a toast that the user did not ask for. It pulls them away from their work.
    • Keep the text short and complete: "Message sent to Alex", not "Success!".

    Check your understanding · Exercise 5

    A user clicks "Add to cart" twice. Your status region says "Added to cart" both times, but the screen reader speaks it only once. Why? Pick one.

    Hiding things on purpose: sr-only and aria-hidden

    Sometimes the screen and the accessibility tree should not match. An icon next to the word "Delete" adds nothing for a screen reader user, while a heading that the layout makes unnecessary for sighted users can help screen reader users jump around. There are tools for both directions, and mixing them up causes some of the worst bugs.

    Visible to screen readers only: the sr-only pattern

    To keep text in the accessibility tree but off the screen, use the visually hidden pattern, usually named sr-only or visually-hidden. The element still takes part in the tree, gets read, and can be found with quick keys; it just occupies no visible space.

    css
    .sr-only {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
      border: 0;
    }
    
    A common visually hidden class, close to what Tailwind and Bootstrap ship. Each line stops a different way the text could leak onto the screen or wrap into a tall column.

    Why not display: none or visibility: hidden? Those remove the element from the accessibility tree as well. Older tricks have problems too: some screen readers skip font-size: 0 text, and elements moved far off screen can cause odd scrolling when focused, especially in right-to-left layouts.

    Good uses: extra context on repeated links (a visible "Read more" followed by hidden text "about the spring sale"), a heading for a section that is visually obvious, the text of live regions, and a skip link that becomes visible on focus. Bad use: hiding a whole second version of the content. Two versions drift apart, and sighted screen reader users (there are many) see one thing and hear another.

    Hidden from screen readers only: aria-hidden

    aria-hidden="true" does the opposite. The element stays on screen but is removed from the accessibility tree, together with everything inside it. Use it for decorative icons next to visible text, duplicate content (a card where the image and the title link to the same page), and visual flourishes that would be read as nonsense.

    It does not remove the element from the Tab order. That is the trap: a link or button inside an aria-hidden container can still receive keyboard focus, and the screen reader then lands on something that does not exist for it, and says nothing or something confusing. If you need to hide a part of the page from everyone who is not looking at it, such as the content behind a modal, use the inert attribute, which removes it from the tree and from focus and clicks at once.

    A matrix of seven hiding techniques against three questions: is it seen, is it read by a screen reader, and can it take keyboard focus. display none, the hidden attribute, and visibility hidden: not seen, not read, not focusable. aria-hidden true: seen, not read, but still focusable, which is a trap. inert: seen, not read, not focusable. The sr-only class: not seen, read, and focusable. opacity 0: not seen, but still read and focusable, another trap.seenreadfocusabledisplay: nonenononohidden attributenononovisibility: hiddennononoaria-hidden=trueyesnoyesinertyesnonosr-only classnoyesyesopacity: 0noyesyes
    The two marked cells are the bugs: aria-hidden leaves controls focusable, and opacity: 0 leaves invisible content read and focusable. Use inert to hide a region from both.

    Most hiding bugs come from picking a technique from the wrong row: aria-hidden on something focusable, display: none on a live region, or sr-only on content that sighted keyboard users also need to see when it gets focus.

    Check your understanding · Exercise 6

    When a modal opens, you set aria-hidden="true" on the main page wrapper. What problem remains? Pick one.

    Common mistakes and how to spot them

    Most screen reader bugs come from a short list of mistakes. Each looks fine on screen, which is why they ship.

    Naming mistakes

    • aria-label on a plain div or span. These have the generic role, which ARIA 1.2 does not allow to be named, so screen readers ignore the label or read it inconsistently. Fix: put the text on screen, or give the element a real role that can be named, such as region or img.
    • Placeholder as the only label. The placeholder disappears when the user types, is often low contrast, and is only a last-resort name. Fix: a visible label.
    • The role inside the name. aria-label="Close button" is heard as "Close button, button". Fix: name it "Close" and let the role speak for itself.
    • Names that differ from the visible text. A button showing "Send" with aria-label="Submit form" breaks Label in Name. Fix: drop the aria-label or start it with the visible words.
    • Images with useless alt. alt="image", alt="IMG_2041.jpg", or "Picture of" in front of every description. Decorative images need alt="" (empty, not missing), which removes them from the tree.

    Hiding mistakes

    Hiding bugs are worse than naming bugs, because the user cannot tell that anything is missing. A badly named button is at least found; wrongly hidden content does not exist for them.

    • aria-hidden="true" on focusable content, or on a parent of it. Symptom: Tab lands on something and the screen reader says nothing or just "blank". axe reports this as aria-hidden-focus. Fix: remove aria-hidden, or use inert, or make the inner controls unfocusable as well.
    • aria-hidden on the whole page body while a modal is open, with the modal inside the body. Everything, including the modal, disappears for the screen reader.
    • Collapsed content hidden with only opacity or height 0. It is invisible but still read and focusable. Fix: add hidden or inert when collapsed, as native details does for you.

    Live region mistakes

    • The region mounted together with its text, from the last chapters. Symptom: works in one pair, silent in another. Fix: render it empty from the start.
    • aria-live on the whole results list. Every result card's text is read when the list changes: dozens of product names and prices in a row. Fix: a separate small status region with a one-line summary.
    • role="alert" for routine messages, or an alert region that already has text at page load. Some screen readers read it as the page loads. Fix: status for routine feedback, and keep alert regions empty until an error happens.
    • Announcing on every keystroke or every progress tick. A file upload that announces 1%, 2%, 3% makes the screen reader unusable. Fix: announce at the start, at 25% steps or every 10 seconds at most, and at the end.
    • A live region on the input itself. Its value changes as the user types, so they hear every character twice.

    Check your understanding · Exercise 7

    A teammate adds aria-live="polite" to the ul that holds 24 search results so that users "hear the results". What do users actually experience? Pick one.

    Testing with VoiceOver and NVDA

    You cannot know what a screen reader says by reading code. The browser computes names, screen readers apply their own rules, and live region timing depends on what else is being spoken. Ten minutes with a real screen reader, and a handful of keys, finds problems no linter can.

    Pick the right pairs

    Test each screen reader with the browser its users actually use, since that pair is what has been tuned together. A sensible minimum for a product team: NVDA with Chrome or Firefox, VoiceOver with Safari on macOS, and VoiceOver with Safari on iOS. Add JAWS with Chrome for enterprise or government products, and TalkBack with Chrome when Android is a big share of your traffic.

    VoiceOver on macOS in five minutes

    The VoiceOver keys you need for most web testing. Turn on the caption panel in VoiceOver Utility to see the speech as text.
    ActionKeys
    Turn VoiceOver on or offCommand+F5
    The VO modifierControl+Option (written VO below)
    Move to next or previous itemVO+Right arrow, VO+Left arrow
    Activate the current itemVO+Space
    Read from here to the endVO+A
    Open the rotor (headings, links, landmarks, form controls)VO+U, then Left and Right arrows to switch lists
    Next headingVO+Command+H
    Stop speakingControl

    On an iPhone, turn VoiceOver on in Settings, under Accessibility, and set the Accessibility Shortcut so that a triple click of the side button toggles it. Swipe right and left to move, double tap to activate, and twist two fingers to open the rotor. Practise turning it off before you turn it on.

    NVDA on Windows in five minutes

    NVDA's essential keys. Tools, then Speech Viewer, opens a window that logs every phrase NVDA speaks.
    ActionKeys
    Start NVDAControl+Alt+N (shortcut created by the installer)
    The NVDA modifierInsert, or Caps Lock in the laptop layout (written NVDA below)
    Quit NVDANVDA+Q
    Read the next line in browse modeDown arrow
    Read everything from hereNVDA+Down arrow
    Jump by heading, landmark, link, field, buttonH, D, K, F, B (Shift to go back)
    List links, headings, landmarksNVDA+F7
    Switch between browse and focus modeNVDA+Space
    Stop speakingControl

    The Speech Viewer is the most useful tool for testing live regions. It writes down every phrase, in order, so you can see exactly when "24 results for running shoes" arrived relative to the typed letters, and whether anything was spoken twice or not at all. VoiceOver's caption panel does the same on the Mac.

    A wireframe of the NVDA Speech Viewer window during a search test. It lists, one per line: Search products, edit; then the letters s, h, o, e, s; then Sort by, combo box, Relevance; then 24 results for shoes. Notes beside it mark the field name, the typing echo, the focus move from Tab, and the live region message arriving once, after the focus speech.NVDA Speech ViewerSearch products editshoesSort by combo box Relevance24 results for shoesfield name and role,then typing echoTab: focus speech,then the live regionmessage, once
    The log shows order and repeats, which you cannot judge by ear alone. Here the status message arrives once, complete, after the Tab announcement: exactly what you want.

    A ten-minute test script

    1. Structure. Open the headings list (NVDA+F7 or the rotor). Do the headings outline the page? Then the landmarks: is there one main, and are repeated navs named?
    2. Tab through every control. For each one, check that you hear a name, the right role, and the state: "Filters, button, collapsed". Silence or just "button" is a bug.
    3. Fill in a form wrongly and submit. Is focus moved to the errors, or are they announced? Is each error tied to its field with aria-describedby?
    4. Trigger every async action without looking. Search, save, add to cart, delete. Close your eyes, or turn on VoiceOver's screen curtain, which blacks out the display. Do you know what happened?
    5. Read the log. In Speech Viewer, look for messages that repeat, arrive late, cut each other off, or never appear.

    Check your understanding · Exercise 8

    Your team has one hour a week for manual screen reader testing on a consumer web app. Which setup gives the best coverage for the time? Pick one.

    In real products and in the interview

    How frameworks and design systems do it

    The shared announcer from chapter 6 is not a toy pattern; the big UI libraries ship it.

    • Angular CDK has a LiveAnnouncer service: announcer.announce("Saved", "polite"). It creates one visually hidden live element in the page and reuses it.
    • React Aria (Adobe) exports an announce function from its live announcer package, used by its combobox, drag and drop, and table components to report things like "3 items selected".
    • GitHub's Primer publishes a live-region custom element (@primer/live-region-element) with its own announce(), and recommends one global instance in the initial HTML of every page.
    • Next.js renders a route announcer: after a client-side navigation, a hidden live region reads the new page's name, taken from document.title, else the first h1, else the URL path. It does not move focus.

    The common thread: live regions are app infrastructure, like a toast system or an error boundary, created once, early, for every feature to call. Teams that let each feature create its own regions end up with the silent and noisy bugs from chapter 8.

    When to bring it up in an interview

    Mention screen readers whenever the design has content that changes without the user moving: a typeahead, a chat, a notification feed, a live dashboard, an infinite scroll list, a form that saves itself, or a toast system. These are common interview questions, and a sentence or two about announcements sets you apart.

    Draw an Announcer box at the app root next to the router and the toast host, with arrows from features that call announce(). For a typeahead, say in about 30 seconds: "The input is a combobox following the APG pattern, with aria-activedescendant for the highlighted option. The result count goes through a polite status region that already exists at the root, and I only announce the latest settled result, about a second after typing stops, so the user is not flooded."

    • Junior: know that icon buttons need names, that alt text matters, and that there is a thing called a live region for dynamic updates.
    • Mid-level: pick polite versus assertive correctly, know the region must exist before it changes, and know when to move focus instead.
    • Senior: design the announcer as shared infrastructure, set rules for message wording and rate, say how it is tested (Testing Library in CI, manual NVDA and VoiceOver passes per release), and weigh trade-offs such as virtualised lists and find-in-page.

    Think first

    You are designing a team chat app. New messages arrive in the open channel several times a minute. How should a screen reader user learn about them?

    Summary

    • Screen readers read the accessibility tree, not pixels or HTML. Every node is spoken as name, role, and state, so all three must be right.
    • The accessible name comes from the first non-empty source in a fixed order: aria-labelledby, aria-label, the native label, content (for some roles), title, placeholder. Prefer visible native labels, and keep visible words in the name.
    • Users move by headings, landmarks, links, and fields in browse mode; widgets with the right roles switch the screen reader to focus mode so your arrow keys work.
    • Changes away from focus need a decision: move focus if the user must act, announce politely if they need to know, stay quiet if it changes constantly.
    • Use role="status" for most announcements and role="alert" only for urgent errors. The region must exist, empty, before its text changes.
    • Send all announcements through one shared announcer at the app root, debounce bursty ones like search counts, and make each message complete on its own.
    • aria-hidden never goes on focusable content; use inert to hide whole regions and the sr-only class to hide text visually while keeping it readable.
    • Test with NVDA and VoiceOver in their usual browsers, and read the Speech Viewer or caption panel to check what was said and when.

    In the interview

    Questions interviewers ask about this. Answer each out loud first, then compare.

    Question 1

    What is the accessibility tree, and why does it matter to a frontend engineer?

    Question 2

    How does the browser decide the accessible name of a button?

    Question 3

    When would you use aria-live polite versus assertive, and what are role=status and role=alert?

    Question 4

    Design the screen reader experience for a search-as-you-type box.

    Question 5

    What is the difference between aria-hidden, display: none, and an sr-only class?

    Practice it

    These design problems lean on screen readers and live regions.

    References

    Read next