Skip to main content
Skip to docs content

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.

HTML (passes Axe/Lighthouse)
<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.

HTML (passes Axe/Lighthouse)
<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.

HTML (passes Axe/Lighthouse)
<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.

HTML (passes Axe/Lighthouse)
<button>
  <span class="icon">&#xE87C;</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.

HTML (passes Axe/Lighthouse)
<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

Problematic component
<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.

Fixed version
<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

Problematic component
<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.

Fixed version
<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

Problematic component
<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
    Admin

Root 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.

Fixed version
<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
    Admin

Manual 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 component2–10 minutesUnder 1 second
SetupInstall screen reader, learn keyboard shortcuts, configure settingsnpm install or paste into web tool
Feedback loopChange code → rebuild → switch to screen reader → navigate → listenChange code → run command → read output
CI/CDNot practical (requires OS-specific runners + screen reader)Any runner, any OS
Dynamic contentFull support (live regions, focus, JS interactions)Static HTML only
AccuracyGround truthHeuristic approximation
Regression detectionManual 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.

Terminal
# 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.

scripts/a11y-stories.sh
#!/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.

.github/workflows/a11y-pr.yml
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
          fi

Snapshot Testing

Treat screen reader output like a visual snapshot. Store the expected output and fail tests when it changes unexpectedly.

tests/a11y-snapshot.test.ts
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.

HTML
<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.

HTML
<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.

HTML
<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".

HTML
<!-- 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.

HTML
<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.

HTML
<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.

HTML
<!-- 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:

PatternNVDAJAWSVoiceOver
Landmarksnavigation landmarknavigation regionnavigation
HeadingsName, heading level NName, heading level Nheading level N, Name
Disabledunavailableunavailabledimmed
Text inputediteditedit text
Mixed checkboxhalf checkedpartially checkedmixed
Imagesgraphicgraphicimage

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.