Understanding Screen Readers
A screen reader is software that reads digital text aloud and converts web content into speech or braille. For blind or low-vision users, it's their primary way to interact with websites.
Screen readers do more than just read text—they interpret the structure and semantics of your HTML to help users navigate:
- Announce headings and let users jump between them
- List all links on a page
- Navigate by landmarks (header, nav, main, footer)
- Announce form labels and input types
- Read ARIA attributes to understand dynamic content
This is why semantic HTML and proper ARIA usage are so important—they provide the information screen readers need to make sense of your content.
Common Screen Readers
- JAWS (Job Access With Speech) – Popular commercial screen reader for Windows
- NVDA (NonVisual Desktop Access) – Free, open-source screen reader for Windows
- VoiceOver – Built into macOS and iOS
- TalkBack – Built into Android
- Narrator – Built into Windows
You don't need to test with all of them, but testing with at least one (like NVDA or VoiceOver since they're free/built-in) is invaluable.
How Screen Readers Navigate
Screen reader users don't typically listen to a page from top to bottom. They navigate efficiently using:
Keyboard Shortcuts
- H key – Jump to next heading (1-6 for specific levels)
- Tab – Move through interactive elements
- Arrow keys – Read line by line or word by word
- R key – Jump to regions/landmarks
- D key – Jump to landmarks
- F key – Jump to form controls
- T key – Jump to tables
This is why proper semantic structure is crucial. If you use <div class="heading"> instead of <h2>, screen reader users can't jump to it.
Rotor/Elements List
Screen readers can generate lists of elements:
- All headings on the page
- All links
- All form controls
- All landmarks
Users can scan these lists and jump directly to what they need. This makes descriptive headings and link text essential—a list of 20 "Click here" links is useless.
Making Dynamic Content Accessible
Modern websites update content without full page reloads. Screen readers need to know about these changes.
Status Messages (WCAG 4.1.3)
When content updates dynamically (like form submission results, loading indicators, or notification messages), screen readers might not notice unless you tell them.
ARIA Live Regions solve this:
role="status" or aria-live="polite"
For non-critical updates that can wait until the user pauses:
<div role="status" aria-live="polite">
5 items added to cart
</div>
<div id="search-results" aria-live="polite">
Showing 23 results for "accessibility"
</div>
role="alert" or aria-live="assertive"
For important messages that should interrupt immediately:
<div role="alert">
Error: Password must be at least 8 characters
</div>
<div aria-live="assertive">
Connection lost. Attempting to reconnect...
</div>
Best Practices for Live Regions
- The container should exist in the DOM when the page loads (even if empty)
- Update the content with JavaScript—don't create and insert a new live region
- Use
politefor most things; reserveassertivefor truly critical messages - Keep messages concise and clear
- Don't overuse—too many announcements overwhelm users
<!-- In your HTML -->
<div id="status" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- In your JavaScript -->
const statusEl = document.getElementById('status');
statusEl.textContent = 'Form submitted successfully';
// Clear after a few seconds
setTimeout(() => {
statusEl.textContent = '';
}, 5000);
Accessible Modal Dialogs
Modal dialogs are one of the most common interactive patterns, and they need careful handling for accessibility.
Requirements for Accessible Modals
- Focus Management: Move focus into the modal when it opens
- Focus Trap: Keep focus within the modal (tab loops through modal elements)
- Escape to Close: Allow Escape key to close the modal
- Return Focus: Return focus to the triggering element when closed
- Inert Background: Make background content non-interactive
- ARIA Attributes: Use proper roles and properties
Modal Implementation
<button id="open-modal">Open Dialog</button>
<div
id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
hidden>
<h2 id="modal-title">Confirm Action</h2>
<p id="modal-desc">Are you sure you want to delete this item?</p>
<button id="confirm">Confirm</button>
<button id="cancel">Cancel</button>
<button id="close-modal" aria-label="Close">×</button>
</div>
<script>
const modal = document.getElementById('modal');
const openBtn = document.getElementById('open-modal');
const closeBtn = document.getElementById('close-modal');
const cancelBtn = document.getElementById('cancel');
let previousFocus;
function openModal() {
// Save current focus
previousFocus = document.activeElement;
// Show modal
modal.removeAttribute('hidden');
// Add background inertness (make background non-interactive)
document.body.setAttribute('aria-hidden', 'true');
modal.removeAttribute('aria-hidden');
// Move focus to first focusable element (or close button)
closeBtn.focus();
// Add event listeners
modal.addEventListener('keydown', handleModalKeydown);
}
function closeModal() {
// Hide modal
modal.setAttribute('hidden', '');
// Remove background inertness
document.body.removeAttribute('aria-hidden');
// Return focus
if (previousFocus) {
previousFocus.focus();
}
// Remove event listeners
modal.removeEventListener('keydown', handleModalKeydown);
}
function handleModalKeydown(e) {
// Close on Escape
if (e.key === 'Escape') {
closeModal();
return;
}
// Trap focus on Tab
if (e.key === 'Tab') {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
</script>
Common Interactive Patterns
Accordion (Expand/Collapse)
<div class="accordion">
<h3>
<button
aria-expanded="false"
aria-controls="section1-content"
id="section1-header">
Section 1
</button>
</h3>
<div id="section1-content" hidden>
<p>Content for section 1...</p>
</div>
</div>
<script>
const button = document.getElementById('section1-header');
const content = document.getElementById('section1-content');
button.addEventListener('click', () => {
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !isExpanded);
content.hidden = isExpanded;
});
</script>
Key points:
- Use
<button>for the header (not div or h3 directly) - Update
aria-expandedwhen state changes - Use
hiddenattribute ordisplay: nonefor collapsed content
Tabs
<div class="tabs">
<div role="tablist" aria-label="Sample Tabs">
<button role="tab" aria-selected="true" aria-controls="panel1" id="tab1">
Tab 1
</button>
<button role="tab" aria-selected="false" aria-controls="panel2" id="tab2">
Tab 2
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">
<p>Content for tab 1</p>
</div>
<div role="tabpanel" id="panel2" aria-labelledby="tab2" hidden>
<p>Content for tab 2</p>
</div>
</div>
Keyboard behavior:
- Arrow keys navigate between tabs
- Enter/Space activates a tab
- Only the active tab is in tab order (use
tabindex="-1"on inactive tabs)
Carousels
Accessibility requirements:
- Provide Previous/Next buttons (keyboard accessible)
- Add pause/play controls (WCAG 2.2.2)
- Use
aria-labelon the container ("Image carousel") - Announce slide changes with
aria-live="polite" - Make slide indicators keyboard accessible
- Don't auto-advance too quickly (or at all without user control)
Testing with Screen Readers
Getting Started with NVDA (Windows)
- Download free from nvaccess.org
- Install and restart
- Launch NVDA (it starts talking immediately)
- Open your website in a browser
- Use arrow keys to read through content
- Try
Insert + F7to see elements list - Try
Hto jump through headings
Getting Started with VoiceOver (Mac)
- Press
Cmd + F5to toggle VoiceOver - Open your website in Safari (works best with VoiceOver)
- Use
VO(Control + Option) + arrow keys to navigate - Use
VO + Uto open rotor (elements list) - Use
VO + Cmd + Hto jump through headings
What to Listen For
- Does everything get announced? Text, headings, links, buttons
- Are controls identified properly? "Button: Submit" not "Clickable: Submit"
- Are labels meaningful? "Button: Delete item" not "Button: Icon"
- Does state get announced? "Expanded" vs "Collapsed", "Checked" vs "Not checked"
- Is the reading order logical? Does it match visual order?
- Are images described? Alt text should be read
- Are updates announced? Dynamic content changes
Key Takeaways
- Screen readers rely on semantic HTML and ARIA to understand and navigate content.
- Users navigate by headings, landmarks, links, and form controls—use proper elements.
- Use ARIA live regions (
role="status",role="alert") to announce dynamic updates. - Modal dialogs need focus management, focus trapping, and proper ARIA attributes.
- Interactive widgets (accordions, tabs, carousels) need both keyboard support and ARIA semantics.
- Test with an actual screen reader—NVDA (Windows) or VoiceOver (Mac) are free.
- Listen for proper announcements, clear labels, and logical reading order.
- Update
aria-expanded,aria-selected, and other states when content changes.
Now that you understand how to build accessible content, let's look at how to test and audit your site for accessibility issues—both automated and manual testing approaches.