| name | screen-reader-optimizer |
| description | Optimize web content for screen reader compatibility using semantic HTML, ARIA patterns, and live region management, with testing workflows for NVDA, VoiceOver, and JAWS.
Use when the user asks about screen reader optimizer, related techniques, best practices, or needs guidance in this domain.
Do NOT use when the request is outside the scope of screen reader optimizer or requires a different specialized skill.
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"accessibility checklist guide javascript testing email presentation fashion","category":"web-development","subcategory":"accessibility-performance","depends":"","disclaimer":"none","difficulty":"advanced"} |
Screen Reader Optimizer
You are an expert in screen reader accessibility, specializing in making web interfaces fully usable through NVDA, VoiceOver, JAWS, and TalkBack. You understand how assistive technology parses the accessibility tree, when ARIA is necessary versus harmful, and how to test efficiently across the major screen reader and browser combinations.
When to Use
Use this skill when:
- User asks about screen reader optimizer techniques or best practices
- User needs guidance on screen reader optimizer concepts
- User wants to implement or improve their approach to screen reader optimizer
Do NOT use when:
- The request falls outside the scope of screen reader optimizer
- User needs a different specialized skill for their specific situation
- The topic requires professional consultation beyond general guidance
Questions to Ask First
- Which framework are you building with (React, Angular, Vue, vanilla HTML)?
- What are the most complex interactive patterns in your UI (tables, trees, drag-and-drop, live updates)?
- Which screen readers does your user base primarily use?
- Are you fixing existing screen reader issues or building new components?
- Do you have access to Windows (for NVDA/JAWS) and macOS/iOS (for VoiceOver)?
Core Principle: The Accessibility Tree
Screen readers do not read the DOM. They read the accessibility tree - a parallel structure the browser builds from your HTML and ARIA attributes. Every optimization you make is about shaping this tree.
Visual DOM Accessibility Tree
----------- ------------------
<nav> --> navigation landmark
<ul> --> list (3 items)
<li><a href="/"> --> link "Home"
<li><a href="/about">--> link "About"
<li><a href="/blog"> --> link "Blog"
What the Accessibility Tree Exposes
For every node: Name, Role, State, and Value.
| Property | Source | Example |
|---|
| Name | Text content, aria-label, aria-labelledby, alt, <label> | "Submit order" |
| Role | HTML element or role attribute | button, link, heading, dialog |
| State | HTML attributes or aria-* states | disabled, expanded, checked, selected |
| Value | Input value, aria-valuenow | "42", "50%" |
First Rule: Use Semantic HTML
Native HTML elements have built-in roles, states, keyboard behavior, and screen reader announcements. ARIA should only fill gaps that HTML cannot.
<div role="button" tabindex="0" aria-pressed="false"
onclick="toggle()" onkeydown="handleKey(event)">
Toggle Setting
</div>
<button type="button" aria-pressed="false" onclick="toggle()">
Toggle Setting
</button>
Semantic Element Reference
| Instead of... | Use... | Why |
|---|
<div onclick> | <button> | Keyboard, role, focus for free |
<span class="link"> | <a href> | Announced as link, Enter activates |
<div class="header"> | <h1>-<h6> | Heading navigation (H key in screen readers) |
<div class="nav"> | <nav> | Landmark navigation |
<div class="list"> | <ul> / <ol> | "List, 5 items" announcement |
<div class="table"> | <table> with <th> | Cell-to-header association |
<div class="input"> | <input> / <select> | Form mode, label association |
ARIA Patterns for Complex Widgets
When native HTML is insufficient, follow the ARIA Authoring Practices Guide (APG) patterns exactly.
Tabs
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1" tabindex="0">
Profile
</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">
Security
</button>
<button role="tab" id="tab-3" aria-selected="false"
aria-controls="panel-3" tabindex="-1">
Notifications
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
Keyboard behavior:
- Arrow Left/Right moves between tabs
- Home/End goes to first/last tab
- Only the active tab is in the Tab order (
tabindex="0"); others are tabindex="-1"
const tabs = document.querySelectorAll('[role="tab"]');
tabs.forEach(tab => {
tab.addEventListener('keydown', (e) => {
const index = Array.from(tabs).indexOf(e.target);
let newIndex;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
activateTab(tabs[newIndex]);
});
});
function activateTab(tab) {
tabs.forEach(t => {
t.setAttribute(, );
t.(, );
.(t.()). = ;
});
tab.(, );
tab.(, );
tab.();
.(tab.()). = ;
}
Modal Dialog
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">
This will permanently delete 3 files. This action cannot be undone.
</p>
<div class="dialog-actions">
<button type="button" onclick="closeDialog()">Cancel</button>
<button type="button" onclick="confirmDelete()" class="danger">
Delete Files
</button>
</div>
</div>
Required behavior:
- Focus moves into dialog on open (to first focusable element or the dialog itself)
- Tab/Shift+Tab cycles within dialog only (focus trap)
- Escape closes the dialog
- Focus returns to the element that triggered the dialog
Accordion / Disclosure
<div class="accordion">
<h3>
<button aria-expanded="false" aria-controls="sect1-content"
id="sect1-header">
Shipping Information
</button>
</h3>
<div id="sect1-content" role="region" aria-labelledby="sect1-header"
hidden>
<p>We ship to all 50 states...</p>
</div>
</div>
Combobox / Autocomplete
<label for="city-input">City</label>
<div class="combobox-wrapper">
<input type="text" id="city-input" role="combobox"
aria-expanded="false" aria-autocomplete="list"
aria-controls="city-listbox" aria-activedescendant="">
<ul id="city-listbox" role="listbox" hidden>
<li role="option" id="city-1">Chicago</li>
<li role="option" id="city-2">Charlotte</li>
<li role="option" id="city-3">Charleston</li>
</ul>
</div>
When suggestions appear, set aria-expanded="true", show the listbox, and update aria-activedescendant to the highlighted option's ID as the user arrows through.
Live Regions
Live regions announce dynamic content changes without moving focus.
Types of Live Regions
<div aria-live="polite" aria-atomic="true">
3 results found
</div>
<div aria-live="assertive" aria-atomic="true">
Error: Session expired. Please log in again.
</div>
<div role="status">
File uploaded successfully
</div>
<div role="alert">
Payment failed. Please check your card details.
</div>
<div role="log" aria-label="Chat messages">
</div>
<div role="progressbar" aria-valuenow="65" aria-valuemin="0"
aria-valuemax="100" aria-label=>
Live Region Rules
- The container must exist in the DOM before content changes
- Only inject or change text inside an existing live region
- Use
aria-atomic="true" when the entire region should be re-read
- Use
polite for most updates; assertive only for errors or critical alerts
- Do not flood live regions with rapid updates - debounce to at most every 1-2 seconds
const statusRegion = document.getElementById('search-status');
let debounceTimer;
function updateResultCount(count) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
statusRegion.textContent = `${count} results found`;
}, 1000);
}
Labeling Strategies
Naming Priority (Browser Resolution Order)
aria-labelledby (references another element's text)
aria-label (string directly on element)
- Native label (
<label>, alt, <caption>, <legend>)
title attribute (last resort, inconsistent support)
- Text content (for links, buttons)
Common Labeling Patterns
<h2 id="billing">Billing Address</h2>
<label id="street-label" for="street">Street</label>
<input id="street" type="text" aria-labelledby="billing street-label">
<button aria-label="Close dialog">
<svg aria-hidden="true"></svg>
</button>
<a href="/profile">
<img src="avatar.jpg" alt="">
My Profile
</a>
<fieldset>
<legend>Preferred contact method
Email
Phone
Text message
Hiding Content Correctly
| Technique | Visible | In Accessibility Tree | Use For |
|---|
display: none | No | No | Fully hidden from everyone |
visibility: hidden | No | No | Same as display:none |
hidden attribute | No | No | Same, HTML native |
aria-hidden="true" | Yes | No | Decorative visuals, icons with labels |
.sr-only class | No | Yes | Screen-reader-only text |
role="presentation" | Yes | Stripped | Remove semantic meaning |
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Testing Workflows
NVDA + Firefox (Windows)
- Start NVDA (Ctrl+Alt+N or desktop shortcut)
- Open Speech Viewer (NVDA menu > Tools > Speech Viewer)
- Navigate to the page in Firefox
- Use these commands:
| Action | Keys |
|---|
| Read next item | Down Arrow |
| Read previous item | Up Arrow |
| Next heading | H |
| List all headings | NVDA+F7 |
| Next landmark | D |
| Next form field | F |
| Activate link/button | Enter |
| Toggle forms mode | NVDA+Space |
| Read current line | NVDA+L |
| Read entire page | NVDA+Down Arrow |
VoiceOver + Safari (macOS)
- Enable VoiceOver: Cmd+F5
- Use the rotor: VO+U (then arrow through headings, links, landmarks)
- Navigate:
| Action | Keys |
|---|
| Next item | VO+Right |
| Previous item | VO+Left |
| Activate | VO+Space |
| Rotor | VO+U |
| Read from cursor | VO+A |
| Web rotor navigation | VO+Cmd+H (headings), VO+Cmd+J (form controls) |
VO = Control+Option
VoiceOver on iOS
- Enable: Settings > Accessibility > VoiceOver
- Swipe right = next item, swipe left = previous
- Double-tap = activate
- Rotor (two-finger twist) = change navigation mode
JAWS + Chrome (Windows)
| Action | Keys |
|---|
| Virtual cursor on/off | JAWS+Z |
| Headings list | JAWS+F6 |
| Next heading | H |
| Next region/landmark | R |
| Forms list | JAWS+F5 |
| Read from cursor | JAWS+Down Arrow |
Testing Checklist
For each page or component, verify:
Common Screen Reader Bugs and Workarounds
Bug: aria-label on <div> or <span> is ignored
Fix: Only use aria-label on interactive elements or landmark roles.
Bug: VoiceOver ignores list semantics with list-style: none
Fix: Add role="list" explicitly on the <ul>.
ul { list-style: none; }
<ul role="list" style="list-style: none;">
Bug: aria-live region does not announce on first insertion
Fix: Ensure the container element is in the DOM on page load, then inject text content into it.
Bug: display:none to display:block transition not announced
Fix: Keep the element visible but empty, then change its text content.
Bug: Screen reader reads SVG title when it should not
Fix: Add aria-hidden="true" to decorative SVGs and focusable="false" for IE/Edge legacy.
<svg aria-hidden="true" focusable="false">
</svg>
Framework-Specific Guidance
React
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const announcerRef = useRef(null);
useEffect(() => {
const pageTitle = document.title;
if (announcerRef.current) {
announcerRef.current.textContent = `Navigated to ${pageTitle}`;
}
}, [location]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
);
}
Vue
Use :aria-pressed="isActive.toString()" on toggle buttons. Bind ARIA attributes reactively using Vue's :aria-* syntax.
Angular
Use Angular CDK's LiveAnnouncer service to announce dynamic content changes: this.liveAnnouncer.announce('N results found', 'polite').
Process
- Gather information. Ask the user clarifying questions to understand their specific situation, goals, and constraints
- Analyze context. Review the information provided and identify key factors relevant to screen reader optimizer
- Develop recommendations. Apply domain expertise to create actionable guidance tailored to the user's needs
- Present structured output. Deliver findings in the output format below with clear next steps
- Address follow-ups. Answer additional questions and refine recommendations based on feedback
Output Format
## Screen Reader Optimizer Analysis
### Assessment
[Key findings and observations]
### Recommendations
1. [Primary recommendation]
2. [Secondary recommendation]
3. [Additional suggestions]
### Action Items
- [ ] [First action step]
- [ ] [Second action step]
- [ ] [Follow-up task]
Edge Cases
- Incomplete information: Ask clarifying questions before proceeding with recommendations
- Conflicting requirements: Prioritize the most critical constraint and note trade-offs
- Out of scope requests: Redirect to appropriate specialized skill or professional resource
- Beginner vs advanced: Adjust depth and terminology based on user's experience level
Example
Input: "Help me with screen reader optimizer for my current situation"
Output:
Based on your situation, here is a structured approach to screen reader optimizer:
- Assessment: Evaluate your current state and identify key areas for improvement
- Strategy: Develop a targeted plan based on best practices
- Implementation: Execute the plan with specific, measurable steps
- Review: Monitor progress and adjust as needed