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.
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.
| Screen reader | Platform | Cost | Usual browser |
|---|---|---|---|
| JAWS | Windows | Paid licence | Chrome, Edge |
| NVDA | Windows | Free and open source | Chrome, Firefox, Edge |
| VoiceOver | macOS, iOS, iPadOS | Built in | Safari |
| TalkBack | Android | Built in | Chrome |
| Narrator | Windows | Built in | Edge |
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.
- The DOM and CSS. Your elements, attributes, ARIA, and styles.
display: noneandhiddenremove things here. - 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
divused only for layout. - 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.
- 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.
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.
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
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.
| Key (NVDA and JAWS) | Jumps to | Needs in your markup |
|---|---|---|
| H, or 1 to 6 | Next heading, or next heading of that level | Real h1 to h6 in a sensible order |
| D (NVDA) or R (JAWS) | Next landmark | main, nav, header, footer, labelled regions |
| K (NVDA), or Tab | Next link, or next focusable element | Real a href links and buttons |
| F | Next form field | Real inputs with labels |
| B | Next button | Real button elements |
| T | Next table | Data 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.
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:
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.aria-label: a string you write directly on the element.- The native label from HTML: a
labelelement tied to an input,alton an image,legendfor afieldset,captionfor a table, thetitleelement inside an SVG. - The element's own content: only for roles that allow it, such as
button,link,heading,tab,option,menuitem, and table cells. The text insidebuttonbecomes its name. title: the tooltip attribute, used only as a last resort.placeholder: for text inputs, used only when nothing else exists.
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.
<button><svg aria-hidden="true"></button>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.
<!-- 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>
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.
<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>
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.
<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>
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.
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.
| Role | Implicit politeness | Implicit atomic | Use for |
|---|---|---|---|
status | polite | true | Result counts, save confirmations, progress text. The HTML output element has this role. |
alert | assertive | true | Urgent, time-sensitive errors and warnings |
log | polite | false | Chat messages, activity feeds, console output: new lines appended in order |
timer | off | false | A visible countdown that should not speak every second |
marquee | off | false | Tickers and scrolling news that change often |
Decision Which live region should a message use?
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.
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?
| What happened | Best response | Why |
|---|---|---|
| Search or filter results updated | Polite announcement: "24 results for running shoes" | The user is still typing or choosing; focus must stay put |
| Form saved in the background | Polite: "Changes saved" | Confirms the action without interrupting |
| Item added to cart | Polite: "Added Blue running shoes. Cart has 3 items" | The button stays focused so the user can carry on |
| Form submit failed validation | Move focus to an error summary or the first invalid field | The user must act, and focus takes them there |
| Route change in a single-page app | Move focus to the new page heading | Like a page load; see the deep-focus-management lesson |
| Session about to expire | Assertive, plus a dialog with focus | Urgent and needs a decision |
| A list item deleted | Move focus to a neighbour, then a polite "Deleted" | Focus would be lost with the removed element |
| Typing indicator, live cursor moves | Usually nothing | Frequent 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.
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.
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.
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);
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.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
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.
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-labelon a plaindivorspan. These have thegenericrole, 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 asregionorimg.- 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 thearia-labelor 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 needalt=""(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 asaria-hidden-focus. Fix: removearia-hidden, or useinert, or make the inner controls unfocusable as well.aria-hiddenon 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
hiddenorinertwhen collapsed, as nativedetailsdoes 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-liveon 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:statusfor 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
| Action | Keys |
|---|---|
| Turn VoiceOver on or off | Command+F5 |
| The VO modifier | Control+Option (written VO below) |
| Move to next or previous item | VO+Right arrow, VO+Left arrow |
| Activate the current item | VO+Space |
| Read from here to the end | VO+A |
| Open the rotor (headings, links, landmarks, form controls) | VO+U, then Left and Right arrows to switch lists |
| Next heading | VO+Command+H |
| Stop speaking | Control |
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
| Action | Keys |
|---|---|
| Start NVDA | Control+Alt+N (shortcut created by the installer) |
| The NVDA modifier | Insert, or Caps Lock in the laptop layout (written NVDA below) |
| Quit NVDA | NVDA+Q |
| Read the next line in browse mode | Down arrow |
| Read everything from here | NVDA+Down arrow |
| Jump by heading, landmark, link, field, button | H, D, K, F, B (Shift to go back) |
| List links, headings, landmarks | NVDA+F7 |
| Switch between browse and focus mode | NVDA+Space |
| Stop speaking | Control |
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 ten-minute test script
- 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 repeatednavs named? - 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.
- 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? - 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?
- 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
LiveAnnouncerservice:announcer.announce("Saved", "polite"). It creates one visually hidden live element in the page and reuses it. - React Aria (Adobe) exports an
announcefunction 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-regioncustom element (@primer/live-region-element) with its ownannounce(), 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 firsth1, 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
alttext 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?
Use role="log" on the message list, or better, a separate hidden log that receives a short version of each new message ("Alex: lunch at 1?"), so the full markup of each message, with reactions and buttons, is not read.
Announce only messages in the channel the user is viewing. For other channels, update the unread counts in the sidebar names ("design, 3 unread") without announcing.
Batch bursts: if 10 messages arrive in 2 seconds, say "10 new messages" rather than reading all of them.
Offer a setting to turn message announcements off, because some users prefer to read the log themselves with quick keys.
Never move focus when a message arrives; the user may be typing a reply.
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 androle="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-hiddennever goes on focusable content; useinertto hide whole regions and thesr-onlyclass 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?
It is a second tree the browser builds from the DOM, keeping only what assistive technology needs: each node's role, name, states, value, and description.
The browser exposes it through the operating system's accessibility API, and screen readers read it from there. They never read HTML or pixels directly.
So what a screen reader user gets is decided by our markup: real elements give correct roles for free, labels give names, and ARIA states give state. Layout divs drop out.
I check it in the DevTools Accessibility pane before opening a screen reader.
Question 2
How does the browser decide the accessible name of a button?
It follows the accname algorithm and stops at the first non-empty source: aria-labelledby, then aria-label, then native labelling (like a label element or alt), then the button's own text content, then title.
Higher sources hide lower ones, so an aria-label overrides the visible text. That is why I avoid aria-label when there is visible text, and keep the visible words at the start of the name when I must use one, for WCAG 2.5.3 and voice control users.
Icon-only buttons are the common gap: they need aria-label or visually hidden text.
Question 3
When would you use aria-live polite versus assertive, and what are role=status and role=alert?
Polite waits for the screen reader to finish its current speech; assertive interrupts. Polite fits nearly everything: result counts, saves, cart updates.
Assertive is for urgent things the user must act on now, like a failed payment or session expiry, because it can cut off other speech.
role="status" is a polite, atomic live region; role="alert" is an assertive, atomic one. I use those roles rather than raw aria-live when they fit.
Whichever I use, the region exists in the page from the start, and I change only its text.
Question 4
Design the screen reader experience for a search-as-you-type box.
The input has a visible label or a clear aria-label, and if it shows suggestions it follows the APG combobox pattern.
Focus stays in the input. A polite status region, rendered empty at load, announces the count of the latest settled results, such as "24 results for running shoes", about a second after typing stops.
Stale responses are dropped so old counts are never spoken. Zero results are stated plainly, and a slow search says "Searching" only after about a second.
I would verify it with NVDA's Speech Viewer and VoiceOver's caption panel to check timing and that nothing repeats.
Question 5
What is the difference between aria-hidden, display: none, and an sr-only class?
display: none hides from everyone: not drawn, not in the accessibility tree, not focusable.
aria-hidden="true" hides only from the accessibility tree; the element is still drawn and its controls can still take focus, so it must never wrap focusable content.
The sr-only pattern does the reverse: the element is in the tree and read, but takes no visible space. It is how live regions and extra link context are usually hidden.
To hide a region from both focus and the tree, such as the page behind a modal, I use inert.
Practice it
These design problems lean on screen readers and live regions.
- Design a toast notification systemEasy
- Design an OTP inputEasy
- Design a loading skeleton systemEasy
- Design a countdown timerEasy
- Design a file uploaderMedium
- Design a tag (multi-select) inputMedium
- Design an @-mention inputMedium
- Design a search results pageMedium
- Design product ratings and reviewsMedium
- Design sign up and sign inMedium
- Design a real-time leaderboardMedium
- Design an LLM chat interface with streaming answersMedium
- Design a dropdown menuMedium
- Design an online chess gameMedium
- Design an online exam platformMedium
- Design a combobox for 100,000 optionsHard
- Design an embeddable code editorHard
- Design a PDF viewerHard
- Design live comments for a sports streamHard
- Design undo and redoHard
- Design a live cricket score page for 50 million fansHard
- Design a multiplayer trivia gameHard
- Design an online judge like LeetCodeHard
- Design a web-based terminalHard
- Design a log viewer for millions of linesHard
- Design a video call web app like Google MeetHard
- Design a writing assistant like GrammarlyHard
References
- ARIA live regions MDN's guide to aria-live, aria-atomic, aria-relevant, and the live region roles.
- Accessible Name and Description Computation 1.2 The W3C algorithm that decides every element's name and description.
- WAI-ARIA 1.2 The spec for roles, states, and properties, including status, alert, log, and aria-hidden.
- Screen Reader User Survey results WebAIM's tenth survey (2024): which screen readers and browsers people use.
- Using NVDA to evaluate web accessibility WebAIM's practical guide to NVDA keys for testers.
- Using VoiceOver to evaluate web accessibility The same guide for VoiceOver on macOS.
- Understanding Success Criterion 2.5.3: Label in Name Why the accessible name must contain the visible label.
Read next
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.