Advanced Guide
Deep dives into screen reader behavior, debugging workflows, accuracy considerations, and integration patterns for teams shipping accessible software.
Why Accessibility Audits Pass But Users Still Struggle
Rule-based tools like Axe and Lighthouse check for WCAG violations — missing alt text, low contrast, missing form labels. But passing those checks doesn't mean the experience is good. These examples all pass automated audits yet produce confusing screen reader output.
Card with clickable div passes Axe, confuses screen readers
A card component uses a wrapping div with onClick. Axe doesn't flag it because there's no WCAG rule against clickable divs with visible text.
<div class="card" onclick="navigate('/post/123')">
<img src="thumb.jpg" alt="Blog post thumbnail" />
<h3>Understanding ARIA</h3>
<p>A deep dive into ARIA attributes...</p>
</div>Developer assumes
Users will click the card to navigate. The image has alt text, headings are correct.
Screen reader says
Understanding ARIA A deep dive into ARIA attributes...
No role, no interactive affordance. Screen reader users encounter static text with no indication it's clickable or navigable. They'll skip right past it looking for links.
Tooltip content invisible to screen readers
A tooltip appears on hover with important context. Axe passes because the trigger has visible text.
<button>Delete</button> <div class="tooltip" role="tooltip" id="tip1"> This action cannot be undone </div>
Developer assumes
The tooltip provides helpful context. The button has a clear label.
Screen reader says
Delete, button
The tooltip exists in the DOM but isn't connected to the button via aria-describedby. Screen reader users hear "Delete, button" with no warning that it's destructive. Add aria-describedby="tip1" to the button.
Tab panel with generic labels
A tab interface uses role="tablist" correctly. Axe validates the ARIA pattern.
<div role="tablist"> <button role="tab" aria-selected="true">Tab 1</button> <button role="tab">Tab 2</button> <button role="tab">Tab 3</button> </div>
Developer assumes
The tab pattern is implemented correctly per ARIA authoring practices.
Screen reader says
Tab 1, tab, selected Tab 2, tab Tab 3, tab
Technically valid ARIA, but "Tab 1" is meaningless. Users hear "Tab 1, tab, selected" and have no idea what content it controls. Use descriptive labels: "Overview", "Pricing", "Reviews".
Icon font leaking into announcements
Icon fonts render glyphs via CSS content or Unicode characters. Axe doesn't check text content quality.
<button> <span class="icon"></span> <span class="label">Favorite</span> </button>
Developer assumes
The button has visible text "Favorite" next to the icon.
Screen reader says
Favorite, button
The icon Unicode character is announced as gibberish before the label. Screen readers read all text content. Add aria-hidden="true" to the icon span.
Visually grouped radio buttons without fieldset
Radio buttons are visually grouped with a heading. Each has a label. Axe passes.
<h3>Shipping speed</h3> <label><input type="radio" name="ship" /> Standard</label> <label><input type="radio" name="ship" /> Express</label> <label><input type="radio" name="ship" /> Overnight</label>
Developer assumes
Users can see the "Shipping speed" heading above the radios.
Screen reader says
Shipping speed, heading level 3 Standard, radio button, not checked Express, radio button, not checked Overnight, radio button, not checked
When a screen reader user tabs directly to the radio group, they hear "Standard, radio button" with no group context. The heading isn't programmatically associated. Wrap in a fieldset with a legend.
Debugging Accessibility Like a User Hears It
Step-by-step breakdowns of real accessibility bugs — diagnosed using Speakable output the same way you'd use browser DevTools to debug a visual bug.
Modal dialog that traps focus but announces nothing
<div class="modal-overlay" style="display:block">
<div class="modal">
<h2>Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
</div>Speakable output (the bug)
Confirm deletion Are you sure you want to delete this item? Cancel, button Delete, button
Root cause
The modal div has no role="dialog" and no aria-modal="true". Screen readers don't announce it as a dialog, don't trap virtual cursor inside it, and don't announce the dialog title when it opens. Users may not realize a modal appeared.
<div class="modal-overlay" style="display:block">
<div class="modal" role="dialog" aria-modal="true"
aria-labelledby="modal-title">
<h2 id="modal-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
</div>Fixed output
Confirm deletion, dialog Confirm deletion, heading level 2 Are you sure you want to delete this item? Cancel, button Delete, button
Search results count announced as static text
<div class="search-results">
<span class="count">24 results found</span>
<ul>
<li><a href="/r/1">Result one</a></li>
<!-- ... -->
</ul>
</div>Speakable output (the bug)
24 results found list Result one, link
Root cause
The results count is static text with no live region. When search results update dynamically, screen reader users won't hear the count change. They have to manually navigate back to find it. For the static HTML case, the output is technically correct — but the pattern signals a likely dynamic content issue.
<div class="search-results">
<span class="count" role="status" aria-live="polite"
aria-atomic="true">24 results found</span>
<ul>
<li><a href="/r/1">Result one</a></li>
<!-- ... -->
</ul>
</div>Fixed output
24 results found list Result one, link
Data table missing headers
<table>
<tr>
<td><strong>Name</strong></td>
<td><strong>Email</strong></td>
<td><strong>Role</strong></td>
</tr>
<tr>
<td>Alice</td>
<td>alice@co.com</td>
<td>Admin</td>
</tr>
</table>Speakable output (the bug)
table
row
Name
Email
Role
row
Alice
alice@co.com
AdminRoot cause
The first row uses <td> with <strong> instead of <th>. Screen readers can't identify column headers, so when users navigate cells, they won't hear "Name: Alice" — just "Alice" with no column context.
<table>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
<tr>
<td>Alice</td>
<td>alice@co.com</td>
<td>Admin</td>
</tr>
</table>Fixed output
table
row
Name, column header
Email, column header
Role, column header
row
Alice
alice@co.com
AdminManual Screen Reader Testing vs Speakable
Both approaches have a place. Here's an honest comparison of when each makes sense.
| Manual (NVDA/VoiceOver) | Speakable | |
|---|---|---|
| Time per component | 2–10 minutes | Under 1 second |
| Setup | Install screen reader, learn keyboard shortcuts, configure settings | npm install or paste into web tool |
| Feedback loop | Change code → rebuild → switch to screen reader → navigate → listen | Change code → run command → read output |
| CI/CD | Not practical (requires OS-specific runners + screen reader) | Any runner, any OS |
| Dynamic content | Full support (live regions, focus, JS interactions) | Static HTML only |
| Accuracy | Ground truth | Heuristic approximation |
| Regression detection | Manual comparison (error-prone) | Automated diff with exit codes |
When manual testing is still required
- Focus management in modals, dialogs, and single-page app navigation
- Live region announcements (toast notifications, loading states)
- Complex keyboard interaction patterns (drag-and-drop, comboboxes)
- Screen reader-specific bugs that differ from the ARIA spec
- Final validation before shipping critical user flows
Using Speakable in Your Development Workflow
Speakable fits at multiple points in the development lifecycle. Here's where it adds the most value.
Local Development
Run Speakable against your component HTML during development for instant feedback. No screen reader setup, no context switching.
# Quick check while developing speakable src/components/Modal.html -s all -f text # Focus on a specific element speakable page.html --selector ".checkout-form" -f audit
Storybook Integration
Add a script that extracts rendered HTML from Storybook stories and runs Speakable against each one. This gives you per-component accessibility output alongside your visual stories.
#!/bin/bash # Build Storybook static output npx storybook build -o storybook-static # Run Speakable against each story's iframe HTML for file in storybook-static/iframe.html; do echo "=== $file ===" speakable "$file" --selector "#storybook-root" -s all -f text done
PR Checks
Surface announcement changes directly in pull requests. When a PR changes screen reader output, the diff makes it visible to reviewers.
name: A11y PR Check
on: pull_request
jobs:
announcement-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm ci
# Get the base branch HTML
- run: git show origin/${GITHUB_BASE_REF}:dist/index.html > /tmp/base.html
# Diff against current
- name: Check for announcement changes
run: |
OUTPUT=$(npx @reticular/speakable dist/index.html --diff /tmp/base.html -f text 2>&1) || true
if [ -n "$OUTPUT" ]; then
echo "## Screen Reader Output Changes" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fiSnapshot Testing
Treat screen reader output like a visual snapshot. Store the expected output and fail tests when it changes unexpectedly.
import { execSync } from 'child_process';
import { readFileSync } from 'fs';
describe('Accessibility snapshots', () => {
it('button component announces correctly', () => {
const output = execSync(
'npx @reticular/speakable src/Button.html -s nvda -f text'
).toString().trim();
expect(output).toMatchInlineSnapshot(`
"Submit, button"
`);
});
it('navigation announces with landmark', () => {
const output = execSync(
'npx @reticular/speakable src/Nav.html -s voiceover -f text'
).toString().trim();
expect(output).toMatchInlineSnapshot(`
"navigation, Main
Home, link
About, link
Contact, link"
`);
});
});How Screen Readers Actually Interpret Your UI
Key behaviors that affect what users hear — and that often surprise developers.
Accessible name computation priority
Screen readers follow a strict priority order when computing what to announce as an element's name: aria-labelledby > aria-label > native label > alt > text content > title. Higher-priority sources completely override lower ones.
<button aria-label="Close" title="Close dialog"> ✕ </button>
NVDA
Close, button
VoiceOver
Close, button
aria-hidden removes entire subtrees
Setting aria-hidden="true" on an element removes it AND all its children from the accessibility tree — even if children have their own roles and names. This is irreversible within that subtree.
<div aria-hidden="true"> <button>Important action</button> <a href="/help">Help</a> </div>
NVDA
(nothing announced)
VoiceOver
(nothing announced)
Role overrides native semantics
An explicit role attribute completely replaces the element's native semantics. A button with role="link" is announced as a link, not a button — even though it still behaves like a button for keyboard interaction.
<button role="link">Read more</button>
NVDA
Read more, link
VoiceOver
Read more, link
Empty alt on images hides them completely
An image with alt="" is treated as decorative and removed from the accessibility tree entirely. This is different from a missing alt attribute, which causes screen readers to announce the filename or "image".
<!-- Decorative (hidden from SR) --> <img src="divider.svg" alt="" /> <!-- Missing alt (problematic) --> <img src="chart.png" />
NVDA
(decorative: nothing) (missing: graphic)
VoiceOver
(decorative: nothing) (missing: image)
Landmarks create navigation shortcuts
Screen reader users can jump between landmarks using keyboard shortcuts (NVDA: D key, VoiceOver: rotor). Unnamed landmarks are listed generically. Named landmarks (via aria-label) are distinguishable.
<nav aria-label="Primary"> <a href="/">Home</a> </nav> <main> <h1>Content</h1> </main> <nav aria-label="Footer"> <a href="/terms">Terms</a> </nav>
NVDA
Primary, navigation landmark Home, link main landmark Content, heading level 1 Footer, navigation landmark Terms, link
VoiceOver
navigation, Primary Home, link main heading level 1, Content navigation, Footer Terms, link
VoiceOver announces role before name for landmarks and headings
VoiceOver uses a different word order than NVDA/JAWS for certain elements. For headings and landmarks, VoiceOver says the role first, then the name. For buttons and links, it says the name first.
<h2>Getting Started</h2> <button>Submit</button>
NVDA
Getting Started, heading level 2 Submit, button
VoiceOver
heading level 2, Getting Started Submit, button
Disabled vs aria-disabled behavior
Native disabled attribute removes the element from tab order AND announces as disabled. aria-disabled="true" announces as disabled but keeps the element focusable — useful when you want users to discover disabled controls and understand why they're inactive.
<!-- Native disabled (not focusable) --> <button disabled>Submit</button> <!-- ARIA disabled (still focusable) --> <button aria-disabled="true">Submit</button>
NVDA
Submit, button, unavailable Submit, button, unavailable
VoiceOver
Submit, button, dimmed Submit, button, dimmed
How Accurate Is Speakable?
Speakable produces heuristic output based on the ARIA specification and documented screen reader behavior. Here's what that means in practice.
How output is derived
Speakable parses HTML into a DOM, walks the tree to compute accessible names (following the W3C accessible name computation algorithm), maps roles (explicit ARIA roles first, then implicit HTML roles), extracts states, and renders the result through screen reader-specific formatters that apply each reader's documented announcement patterns.
Known limitations
- Static HTML only — no JavaScript execution, no dynamic content, no live regions
- No CSS visibility computation (relies on ARIA and HTML semantics for hidden detection)
- Screen reader heuristics vary by version — Speakable targets current stable releases
- Some screen readers have undocumented behaviors that differ from the ARIA spec
- Complex widget patterns (combobox, treegrid) may have simplified output
- Browser-specific rendering differences are not modeled
Cross-reader differences
NVDA, JAWS, and VoiceOver each have distinct announcement patterns. Speakable models these differences:
| Pattern | NVDA | JAWS | VoiceOver |
|---|---|---|---|
| Landmarks | navigation landmark | navigation region | navigation |
| Headings | Name, heading level N | Name, heading level N | heading level N, Name |
| Disabled | unavailable | unavailable | dimmed |
| Text input | edit | edit | edit text |
| Mixed checkbox | half checked | partially checked | mixed |
| Images | graphic | graphic | image |
What this means for you
Speakable is a fast feedback layer for catching structural accessibility issues during development and CI. It catches the majority of problems that would affect screen reader users — missing names, broken hierarchy, incorrect roles, state mismatches. For final validation of complex interactions, dynamic content, and edge-case screen reader behavior, complement with manual testing or runtime tools like Guidepup.