How Screen Readers Work with EchoQuest: A Technical Deep Dive
"Screen reader compatible" might be the most abused phrase in accessibility. Too often it translates to "we added alt text to the images and tried VoiceOver once", and the distance between that and a blind person actually being able to use the app is enormous. Someone who has been turned away by plenty of so-called accessible apps can usually tell within thirty seconds whether a site was designed with them in mind, or whether accessibility got ticked off as a checkbox in the final week. I wanted EchoQuest to pass that thirty-second test, so I baked screen reader support into the architecture instead of bolting it on afterwards.
This post opens the hood on how that works. It's written for developers and accessibility advocates, and for curious players who'd like to know what happens when they press a key. If you build web apps yourself, steal anything useful. My one big lesson is that screen reader support is a design constraint, never a feature you ship later. Treat it as a constraint and most of the work turns out to be structural. Treat it as a feature and you'll be patching holes forever. (Ask me how I know.)
How Screen Readers Work
A screen reader is software that speaks what's on the screen and lets you move around it from the keyboard, or with gestures on a phone. The common ones are NVDA and JAWS on Windows, VoiceOver on Mac and iPhone, TalkBack on Android, and Orca on Linux. People pair them with different browsers, and no single combination owns the field. In WebAIM's tenth screen reader user survey, JAWS with Chrome led at 24.7% of respondents, with NVDA plus Chrome close behind at 21.3%. That spread is exactly why EchoQuest sticks to standard HTML and ARIA instead of betting on one reader's quirks.
Here's the part many developers never learn. A screen reader doesn't look at your page at all. It reads the browser's accessibility tree, a parallel structure the browser builds out of your HTML and ARIA attributes. MDN describes it as an internal representation that the platform's accessibility APIs pass along to assistive technology, and each node carries a name and a role, plus a state and sometimes a description. Layout, colour, icons and animation? Invisible. Only the tree counts. When your HTML is semantic and tidy, the tree is accurate and genuinely useful. When meaning lives only in CSS positioning or visual styling, the tree comes out patchy or plain misleading. A "button" that's really a styled div shows up as generic text, and the person using a screen reader can't operate it the way they'd expect.
So the single highest-leverage habit for any web developer is boring on purpose: use the right HTML element for the job. The W3C's guide to using ARIA even makes it rule number one. If a native element already has the semantics and behaviour you need, use it, instead of repurposing something else and sticking a role on it. Almost every other accessibility technique flows downstream from that choice.
EchoQuest's Approach: Semantic HTML First
Every interactive element in EchoQuest is a real HTML element whose semantics match its purpose:
- Buttons are real button elements, never clickable divs. They take keyboard focus by default, respond to Enter and Space without extra code, and announce themselves as "button". The story choices are buttons too, and each label carries its number along with its text ("Option 2: Follow the smuggler")
- Navigation sits in nav elements with an aria-label, so screen reader users can jump straight to "Site navigation" or "Available choices" with their landmark shortcuts
- Every page has a main content target with a known id (a main element on ordinary pages, a labelled region on the game screen), so the skip link always has somewhere to land
- Narration paragraphs are plain p elements inside a section labelled "Story narration". I'll explain below why that section is deliberately not a live region
- Form inputs always have associated labels, even when the label is visually hidden (the narration speed slider has one). Placeholder text never does the job on its own
- Headings follow a sensible hierarchy. On the game screen the world's name is the h1, and dialogs such as Help open with their own h2, so heading navigation works
- Lists use ul, ol and li rather than styled divs. The choices form an ordered list, so you hear something like "list, 4 items" before the first option
- Tables use real table markup with header cells and an accessible label. My favourite example lives in creator analytics: the little engagement sparkline is just a picture to sighted eyes, so underneath it sits a screen-reader-only table of dates, sessions started and player turns
It all sounds basic, and it is. Yet the basics are exactly where the web keeps tripping. The WebAIM Million 2026 report found detectable WCAG failures on 95.9% of the top million home pages, with an average of 56.1 errors each, and 96% of those errors fell into six familiar categories such as missing form labels and empty buttons. Modern frameworks make it dead easy to ship a custom component that looks perfect and has quietly lost its meaning underneath. So I keep automated checks in the build to catch myself before that happens (more on those in the testing section).
ARIA Live Regions for Dynamic Content
Live regions do the heavy lifting in EchoQuest, although perhaps not in the way you'd guess.
When the GM replies, the page doesn't reload. New content simply appears. Without live regions, a screen reader user wouldn't hear about any of it unless they went looking, which would wreck an interactive story. A live region is a patch of the page that the screen reader keeps an ear on, reading it aloud whenever its content changes.
EchoQuest keeps two of them at the root of every page, both visually hidden. One is role="status" with aria-live="polite", for non-urgent news: damage taken, health recovered, items picked up or lost, skill check results, lore discoveries, auto-saves, the new speed after you press [ or ], and the summary of choices once a turn ends. The other is role="alert" with aria-live="assertive", for things that really shouldn't wait, like hitting zero health, levelling up, unlocking an achievement or running out of free turns for the day. The difference matters. Polite announcements wait for a natural pause in whatever the screen reader is saying, whereas assertive ones cut in immediately. MDN warns that an assertive region can be "extremely annoying and disruptive" and should be used sparingly, and I agree completely. Overdo it and the game feels jittery, even aggressive. Both regions sit in the initial markup, which MDN calls the most reliable way to get them registered. Each message is also cleared and then set again 50 milliseconds later, so the same line said twice in a row still gets read out.
The HP and inventory announcements, by the way, only arrived in May. Before that, the status bar updated in silence, and losing six hit points without a word is no fun when you can't glance at a health bar.
Now, about the narration itself. You might expect it to stream into a live region. It doesn't, and that was a deliberate call. EchoQuest already speaks the narration out loud with its own narrator voice, so pushing the same text into a live region would make VoiceOver or TalkBack read every line on top of the narrator. Two voices at once is chaos. So while the narrator is audible, the narration text is hidden from screen readers. Mute the narrator (volume to zero) and the text opens back up, ready to be read at your screen reader's own rate. The same rule runs through a small helper called narrate(). If the narrator is audible, the message is spoken; if it's muted, the message goes to the polite live region instead. The World Builder Wizard, for instance, uses it to read out each step's prompt.
There's a subtler choice too: how often to update. Feeding every fragment of streamed AI output to a speech engine would sound awful. Instead, the narrator buffers the GM's reply as it arrives and cuts it into whole sentences, and it even refuses to split a line of quoted dialogue in half. The first sentence starts playing while the rest of the reply is still being written, so you get the real-time feel without ever hearing half a word. Browser voices needed similar handling. Chrome's built-in voice used to give up after roughly 15 seconds of a single utterance, so EchoQuest now feeds it short chunks queued back to back.
Timing turned out to be the hardest part of all. On May 16 I realised the choices were being announced and focused while the narrator was still mid-sentence, so screen reader users got two voices at once. Every attribute was technically correct, and the moment was still a mess. Now the choice summary ("3 options available: 1, ...") and the focus move both wait until the narrator falls silent. Honestly, that bug taught me more about live regions than any spec did.
Focus Management
When a dialog opens, focus should move inside it. When it closes, focus should go back to the control that opened it. That's the standard pattern, and the W3C's modal dialog pattern spells it out, including Tab wrapping from the last element back to the first. It takes explicit JavaScript to get right, which is probably why so many production apps skip it. Have you ever tabbed out of a pop-up and found yourself stranded somewhere on the page behind it? That's this pattern missing.
Here's where EchoQuest stands. Its dialogs (Help, the keyboard shortcuts list, the character sheet and the upgrade prompt) carry role="dialog" and aria-modal="true". When the character sheet opens, focus lands on its Close button. Tab and Shift+Tab then cycle inside the sheet instead of escaping into the game behind it, and Escape closes it. A shared trapFocus helper does the same job elsewhere. Opening the inventory or the quest log from the toolbar moves focus to the panel's heading, so you hear where you are right away. I'll be upfront about one gap, though: sending focus back to the exact button that opened a panel isn't consistent everywhere yet. It's on my list.
Route changes are handled centrally. A component called FocusManager watches the URL in the Next.js app, and when you move to a new page (from the library to character creation, say), it moves focus to that page's main heading. Screen reader users immediately hear where they've landed. Without it, focus would stay wherever the click happened, and you might not even notice the page had changed.
Then there's the turn itself. After the GM finishes, focus moves to the first choice by default, which suits players who mostly pick from the list. If you'd rather type, Settings has a "Move focus to the text box after each turn" option, and focus goes to the text box instead.
Skip Links and Heading Navigation
The first focusable element on every page is a "Skip to main content" link. It stays visually hidden until it receives focus, then pops up in the top-left corner. On the game screen two more follow it, "Skip to audio controls" and "Skip to action input". For keyboard and screen reader regulars, these links are the handiest thing on the page, because they let you hop over the header instead of tabbing through it on every single load. I don't take the main one on trust, either. An automated test presses Tab on the home page and checks that the skip link really does land in the main content.
Headings get the same care, so screen reader users can travel by heading level. NVDA and JAWS both use the H key for this, and VoiceOver offers headings in its rotor. It's also how most people get around: in WebAIM's survey, 71.6% of respondents said headings are their first move when hunting for information on a long page. A well-built page becomes quicker to move through by ear than by scrolling with your eyes.
Testing Process
The setups I care about most follow those survey numbers:
- JAWS or NVDA with Chrome on Windows (the two most common pairings in that survey)
- NVDA with Firefox, which still came fourth at 10% of respondents
- VoiceOver with Safari on Mac and iPhone
- TalkBack with Chrome on Android
- Orca with Firefox on Linux
- Keyboard only, with no screen reader at all, just Tab, Enter and the arrow keys, in every browser
Automated checks are the first line of defence. Every change to the web app runs an end-to-end suite in CI where Playwright drives a real browser and axe-core scans each screen against WCAG 2.1 A and AA, failing the build on any serious or critical violation. It covers the home page and the library, character creation and a live game screen. It also does what a person would do: it plays a turn by keyboard, checks that a failed turn gets reported without losing anything, and reloads mid-game to confirm the session survives.
Still, I don't kid myself about how far that reaches. Deque's analysis of more than 13,000 pages found that its automated tests caught 57.38% of issues. That's far better than the old "20 to 30%" folklore, yet it leaves plenty for humans. The bugs that hurt most are the ones where perfectly valid markup produces a confusing experience, and those only surface when you listen. The two-voices bug passed every automated check. So did the problem where the browser narrator cut off a few seconds into a scene, and the stretch when the microphone was blocked on EchoQuest's own pages so voice input quietly did nothing. Each of those got caught by playing the game with my ears, not by reading a report. And no amount of my own listening replaces reports from people who use a screen reader every day, which brings me to the last part.
Reporting Issues
If you run into an accessibility barrier in EchoQuest, please tell me. I treat accessibility bugs as top priority, the kind that hold up a release. The most useful report names your browser, your screen reader and its version, plus the exact steps that led to the problem. I read every one and fix things as fast as I can. Frankly, I'd much rather hear about a bug from one person than have a hundred people hit it in silence because nobody said a word.
Related reading: Browse all EchoQuest blog posts.