[BUG][a11y] Virtualized message list makes conversations difficult to read with a screen reader: unstable buffer, focus jumps, manual scroll needed to read every reply

Status Open
Reported on v2.1.1
Maintainer reply None cached
Activity 1 comment · opened Aug 1, 2026

Summary

The chat message list renders a fixed maximum of about ten messages and removes the rest from the DOM. Sighted users never notice — scrolling re-renders content. For a screen reader user the conversation is difficult to read in practice:

  • Reading a reply takes a manual step on every single turn. To actually navigate and re-read Claude's latest response I have to press the scroll-to-bottom control each time a message arrives. Once per message, every message.
  • Focus is bounced around unpredictably during reading.
  • Messages appear and disappear under the virtual cursor mid-read, so the buffer is never stable.

Screen-reader-specific affordances were shipped — a hidden "Load earlier messages" button, a scroll-to-bottom control. They can be reached. They do not solve the problem. That is the crux of this report: this is not a missing-affordance bug, it is an approach that does not work.

I am blind and use JAWS 2026 as my primary screen reader. This is currently the single biggest barrier to using Claude Desktop for substantial work.

Environment

| | |
|---|---|
| App | Claude Desktop 1.24012.9.0 (MSIX / Microsoft Store), Claude_pzs8sxrjxfjjc |
| OS | Windows 11 |
| Screen readers | JAWS 2026 (primary), NVDA (also affected) |
| Also affects | claude.ai in Chrome — same web code, same behaviour |

The desktop app loads its chat UI from claude.ai, so this is one defect affecting both products, and one fix addresses both.

What actually happens

First-hand account, which matters more here than any conformance citation:

Reading a reply takes a manual step every turn. Claude does announce new messages through an aria-live region, and that is the right thing to have — credit where it is due. But a live-region announcement is a notification, not a substitute for content being present and navigable:

  • It is transient. It cannot be paused, rewound, skimmed by heading, or re-read.
  • For a long response it is unusable as the primary means of reading. One linear pass at speech speed with no navigation is not how anyone reads a detailed technical answer.
  • It is missed entirely if I am in another window, or another Claude session, when the reply lands. Then there is no announcement at all — only the document, which is where the problem is.

So the announcement tells me something arrived. It does not let me read it. For that the message has to be in the document and reachable — and to make that happen I press the scroll-to-bottom control each time a reply arrives, so it is brought into the render window where my screen reader can get to it. Once per message, every message, for the length of the conversation. A per-turn tax on every exchange.

Focus does not stay where I put it. Moving through the conversation, focus gets bounced around as the list re-renders beneath me. I lose my place and have to re-orient.

The buffer is unstable. Messages appear and disappear while I am reading. A screen reader's virtual buffer assumes reasonably stable content; here the content mutates continuously as the render window slides, so what I could read a moment ago is gone when I go back to it.

The provided controls do not fix it. I can reach both the "Load earlier messages" and scroll-to-bottom buttons — screen readers build the virtual cursor from the accessibility tree, not the tab order, so tabindex="-1" does not stop me. But using them is a poor experience: each press shifts the render window, which triggers another round of focus movement and content churn. The controls mitigate a symptom while creating more of the same problem.

A useful way to see it: the accommodation that shipped assumes the screen reader user will drive the render window manually. That is not how screen reader navigation works. Reading is continuous — Say All, heading navigation, arrow keys through a buffer — and it assumes the document is stable while you read it.

Steps to reproduce

  1. Open a conversation containing 30 or more messages.
  2. With JAWS or NVDA, use the headings list, or navigate the message region with the virtual cursor.
  3. Only a handful of messages are present. Scrolling to the top does not make earlier ones reachable.
  4. Send a message and wait for the reply. It is announced via aria-live, but to navigate it, skim it by heading, or re-read any part of it, you must first press scroll-to-bottom to bring it into the render window.
  5. Optionally quantify: document.querySelectorAll('[role="article"]').length

Expected: the whole conversation is navigable and stable, as before this change.
Actual: about ten messages are reachable, the set shifts as you move, and the newest message needs manual intervention to reach.

Mechanism

Measured against the live DOM. The rendered set is [data-rs-index] wrappers inside [data-rocksteady-sizer], within [role="feed"].

The cap is not viewport-derived — it is a fixed item count:

| Condition | Messages rendered |
|---|---|
| Default window | 10 |
| Scroll container forced to 9000px tall | 10 |
| Scroll container forced to 200px tall | 10 |
| Scrolled to the very top of the conversation | 10 (same indices) |

Enlarging the viewport thirteenfold changed nothing; shrinking it changed nothing. This matters for triage: it rules out the workarounds users are likely to try (bigger window, lower zoom), and it means a larger monitor does not help.

Pressing "Load earlier messages" loads older data but does not accumulate rendered messages — it slides the window, which is the churn described above:

click 1: rendered 10, lowest index 51
click 2: rendered 10, lowest index 50
click 3: rendered 10, lowest index 49
click 4: rendered 10, lowest index 48
click 5: rendered 10, lowest index 47
click 6: rendered 10, lowest index 46

Unrendered history is represented by a single aria-hidden spacer (observed at 41566px for 57 messages), which preserves scrollbar geometry but exposes nothing to assistive technology.

Standards mapping

Stated conservatively — the core defect is a functional failure that no single success criterion captures well, and I would rather be precise than expansive.

  • 1.3.2 Meaningful Sequence (A). The sequence available to assistive technology is incomplete and unstable, so it does not preserve the conversation's meaning.
  • 2.4.3 Focus Order (A). Focus moves unpredictably as the render window shifts, disrupting reading order.
  • 4.1.2 Name, Role, Value (A). Content previously exposed to assistive technology is subsequently withdrawn from it.

Explicitly not claimed: 4.1.3 Status Messages. New messages are announced through an aria-live region — that criterion is met, and it was implemented thoughtfully. The defect is that a transient announcement is being relied upon to do a job it cannot do, because the persistent, navigable copy is missing.

This is worth stating plainly for triage: the fix is not more announcements. It is that the message stays in the document.

For procurement contexts these map to EN 301 549 clause 11 and Section 508.

Minor, not claimed as a violation: the "Load earlier messages" button carries tabindex="-1". Screen reader users can still reach it via the virtual cursor, and keyboard-only users have scrolling as an alternative path, so this is not a clean 2.1.1 failure. I mention it only because a control built specifically for assistive technology being excluded from the tab order looks unintentional.

This is a regression

Earlier versions exposed the full conversation. The virtualized list, the scroll-to-bottom control and this behaviour arrived together in a recent update. Conversations that were previously straightforward to read now take considerably more effort.

Recommended fixes

1. Detect assistive technology and do not virtualize.
Electron already surfaces this: app.accessibilitySupportEnabled and the accessibility-support-changed event, with the equivalent AXMode signal available renderer-side. When a screen reader is active, render the full conversation. This is the strongest option because it needs no action, no discovery, and no documentation from the user.

2. Add an explicit setting.
A checkbox in Settings — "Load full conversation history" — defaulting to current behaviour so nothing changes for the majority, with a plain note that enabling it may increase memory use and slow very long conversations. That trade-off is one affected users are well placed to make for themselves. Worth having even alongside (1), because detection is never perfect.

3. Whatever else changes, do not unmount content assistive technology has reached.
Once a message has been exposed to the accessibility tree, keep it there. That alone removes the instability, and it removes the per-turn scroll-to-bottom step, because the reply stays reachable instead of needing to be dragged back into the render window. The existing aria-live region can then do what it is genuinely good at — telling me a reply has arrived — while the document does what it is good at: letting me read it, at my pace, more than once.

4. Please do not solve this with more buttons.
The controls already shipped are evidence that the need was recognised. They do not work, because they assume the user will manually drive the render window, and screen reader reading is continuous rather than viewport-driven. The fix is for the content to be there.

Current workaround

Included in case it helps other screen reader users before a fix ships. It uses only the app's own supported developer-tools switch — no patching — and survives updates.

Create %APPDATA%\Claude\developer_settings.json containing {"allowDevTools": true}, restart, press <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>I</kbd>, and run this in the console.

<details>
<summary>Workaround script</summary>

(() => {
  const U = (window.__unvirt = { pinning: true });
  const origRemove = Node.prototype.removeChild;
  // Refuse to unmount message wrappers, so they accumulate instead of churning.
  Node.prototype.removeChild = function (c) {
    try {
      if (U.pinning && c?.nodeType === 1 && c.hasAttribute?.('data-rs-index') &&
          this.hasAttribute?.('data-rocksteady-sizer')) return c;
    } catch (e) {}
    return origRemove.call(this, c);
  };
  const sizer = () => document.querySelector('[role="feed"] [data-rocksteady-sizer]');
  const idxs = () => [...(sizer()?.querySelectorAll('[data-rs-index]') || [])]
                       .map(w => +w.getAttribute('data-rs-index'));
  const btn = () => [...document.querySelectorAll('button')]
                      .find(b => /load earlier/i.test(b.innerText || ''));
  U.run = async () => {
    let stalls = 0;
    while (btn() && stalls < 3) {
      const before = Math.min(...idxs(), Infinity);
      btn().click();
      let ok = false;
      for (let t = 0; t < 30; t++) {
        await new Promise(r => setTimeout(r, 150));
        if (!btn() || Math.min(...idxs(), Infinity) < before) { ok = true; break; }
      }
      stalls = ok ? 0 : stalls + 1;
    }
    const s = sizer(), best = new Map();          // dedupe, then restore order
    for (const w of s.querySelectorAll('[data-rs-index]')) {
      const i = +w.getAttribute('data-rs-index'), p = best.get(i);
      if (!p) best.set(i, w);
      else {
        const keep = (w.innerText || '').length >= (p.innerText || '').length ? w : p;
        best.set(i, keep);
        const drop = keep === w ? p : w;
        drop.parentNode && origRemove.call(drop.parentNode, drop);
      }
    }
    [...best.entries()].sort((a, b) => a[0] - b[0]).forEach(e => s.appendChild(e[1]));
  };
  U.run();
})();

</details>

With this running, the conversation becomes readable: real message elements, in order, in the real feed, and stable while I read. The difference is immediate and obvious.

It is a workaround, not a fix. It must be re-run each app session and requires developer tools — not a reasonable thing to ask of screen reader users generally. But it does demonstrate that rendering the whole conversation is workable in practice, including on long conversations.

Related

  • #70425 — making Claude usable out of the box for blind and screen reader users. Open. macOS/VoiceOver, covering both CLI and Desktop; its point 4 on heading discipline is the same problem from the other end, since heading navigation cannot work if the content is not in the accessibility tree.
  • #80163 — VS Code extension chat panel: new assistant messages not auto-announced by JAWS. Open, different surface, same theme.
  • #11002 — --screen-reader mode for NVDA and JAWS. Closed as completed in July 2026; that work covers the CLI/TUI. This issue is the desktop GUI, which is a separate surface and not addressed by it.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗