Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~34 min· topic 6 of 8

Topic 10.6

Accessibility for Streaming UI

In one line

A streaming answer changes the page many times a second. Used carelessly it floods a screen reader with fragments. This topic shows how to announce a stream once or twice, keep keyboard focus where the person expects it, make every control reachable, respect reduced motion and keep code readable.

Think of it like this

A radio news reader in a quiet office. If they read every word the moment it is typed into the script, nobody follows. If they wait for the sentence to be complete and read it once, everybody does. A screen reader is that reader, and the live region is the script on their desk.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Screen reader
Software that reads what is on the screen aloud, or sends it to a braille display.
Live region
A part of the page whose changes are reported to assistive technology without the user moving there.
aria-live
The attribute that sets how changes are reported: off, polite or assertive.
aria-busy
An attribute that says an element is being updated, so changes should be held until it is false again.
role="log"
A live region for ordered, appended information such as chat. Implicitly polite, and only changed parts are announced.
role="status"
A polite live region for advisory messages. The whole region is presented when it changes.
Focus
The element that receives the next key press. Shown by the focus ring, found in document.activeElement.
prefers-reduced-motion
A media feature that is reduce when the person asked their system for less animation.
Contrast ratio
A number from 1:1 to 21:1 comparing the brightness of two colours. WCAG AA asks 4.5:1 for normal text.
Success criterion
One numbered, testable requirement in WCAG, such as 1.4.3, with a level: A, AA or AAA.

Step by step

01Count what the reader is told

The first example feeds the 16 words of an answer one at a time into three versions of the same page, and a MutationObserver counts the changes that a conforming screen reader would be told about. The count is a model, not a recording of speech: a change inside a live region counts once, unless the region is aria-busy="true", in which case changes are held and counted once when the flag is released.

Read the result: naive 16, busy 1, status line 2. These are exact because the example controls when each word arrives.

Count what the reader is tolddiagram
Rendering diagram…

02Keep focus where the person is

The second example logs document.activeElement through three cases. A Send button replaced by a different Stop element: focus is on body afterwards. One button whose label changes: focus stays on it. An editor opened from an Edit button: focus moves into the editor, and after Escape it returns to the Edit button.

Note a React detail: two <button>s in the same place with no key are the same DOM element to React, which just changes its props. The example gives them different keys, as real code often does through different components, so the element really is replaced.

03Make every control a real control

The third example prints the Tab order for a message that has a <div onClick> Copy control and one with a <button>. Only the button is reachable; calling focus() on the div does nothing. The fix for the div is a <button type="button">, not tabindex plus key handlers.

04Let the person choose less motion

motionPlan(prefersReduced) returns the cursor, scroll and text-animation choices for each setting. The example also shows that window.matchMedia exists and returns a boolean matches. Its real value depends on the machine, so the example does not print it. In CSS the same idea looks like this:

styles.csswhole filecss
.cursor { animation: blink 1s steps(1) infinite; }

@media (prefers-reduced-motion: reduce) {
  .cursor { animation: none; } /* a steady block instead of a blinking one */
  html { scroll-behavior: auto; }
}

05Measure contrast instead of guessing

The fourth example computes contrast ratios with the WCAG formulas (relative luminance, then (L1 + 0.05) / (L2 + 0.05); the WCAG text gives the small sRGB cut-off as 0.03928 and the sRGB standard as 0.04045, which gives the same result for 8-bit colours). Black on white is 21:1. The light-grey comment colour #999999 on white is 2.85:1 and fails. #6a737d on white passes at 4.82:1. On a dark block, #555555 fails and #569cd6 passes.

The last line shows the edge: 4.499 prints as 4.50 with two decimals but does not pass. Decide pass or fail from the real number, then format it.

06Test it with the real thing

Code can show structure, not speech. Before you ship, move through the page with the keyboard only, then with one screen reader on each platform you support (for example NVDA or VoiceOver) and listen to a streamed answer from start to end. The checks in this topic make regressions visible; they do not replace listening.

Try it yourself

  1. 1

    Switch the live region to assertive

    In the first example add aria-live="assertive" to the role="status" paragraph. The count stays 2, because the count only sees changes.

    What changes is how a screen reader treats them: an assertive update may interrupt what is being read. Counts cannot show that, which is the reason for the manual test. Use assertive for errors that need action, not for status.

  2. 2

    Find the weak colour

    In the fourth example add ["your link colour", "#0a58ca", "#ffffff"] (or your own colours) and run.

    If the ratio is under 4.5 for small text, darken the foreground. Do the same for your dark theme.

  3. 3

    Make the div reachable the hard way

    In the third example give the div tabIndex={0} and role="button" and run.

    The Tab order now includes it. But it still ignores Enter and Space until you also write a key handler, and a screen reader needs the role. A <button> gets all of that for free.

Code & diagrams

A screen reader being flooded, and two ways to stop it

The 16 words arrive one by one (the example controls the timing, so the numbers are exact). "Announcements" is what a conforming screen reader is told, counted from the page's changes. How a particular screen reader and browser handle aria-busy is for you to test.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

words in the answer: 16
naive   announcements: 16
busy    announcements: 1
status  announcements: 2
Where is keyboard focus? Send and Stop, and an editor

document.activeElement is the element with keyboard focus. "body" means focus fell back to the page: the person has lost their place and a screen reader announces nothing useful.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

swap: focus on Send: swap-send
swap: after Send, the button is replaced; focus is on: body
toggle: focus on the button: toggle-btn
toggle: after Send, the same button now says "Stop" and focus is on: toggle-btn
edit: after opening the editor, focus is on: msg-field
edit: after Escape, focus is on: msg-edit
Keyboard reach and reduced motion

The real setting is different on every computer, so the example does not print query.matches. It shows both plans and proves the browser can answer. To try your own setting, turn on "reduce motion" in your operating system and print the value in your own page.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Tab reaches: [ 'copy-good', 'stop', 'prompt' ]
the div copy control is reachable: false | the button copy control is reachable: true
calling focus() on the div works: false
calling focus() on the button works: true
button role: button (implicit) | div role: null
default: { cursor: 'blinking block', scroll: 'smooth', typewriter: 'fade each word in' }
reduce:  { cursor: 'static block', scroll: 'jump', typewriter: 'show text as it arrives, no animation' }
the browser can answer the question: true | media text: (prefers-reduced-motion: reduce)
Contrast of code colours: 4.5 to 1 is a threshold

The formulas follow the WCAG definitions of relative luminance and contrast ratio (this code uses the sRGB cut-off 0.04045, WCAG's text says 0.03928, and the two agree for 8-bit colours). Large text (at least 18 pt, or 14 pt bold) only needs 3:1, but code is normally small, so use 4.5:1 for it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

black on white                21.00:1 passes 4.5:1
white on black                21.00:1 passes 4.5:1
code comment, light grey       2.85:1 FAILS 4.5:1
code comment, darker grey      4.82:1 passes 4.5:1
keyword blue on dark block     5.65:1 passes 4.5:1
dim text on dark block         2.24:1 FAILS 4.5:1
4.499 shown with two decimals: 4.50 | passes: false

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Never release aria-busy

In the first example change the final show(ANSWER, false, "done"); to show(ANSWER, true, "done");.

terminal
$ The console, last three lines
── what you'll see ──
naive announcements: 16
busy announcements: 0
status announcements: 2

Break #2

Replace the focused control

In the second example, in ToggleComposer, give the button a key that changes with the state: <button key={busy ? "stop" : "send"} id="toggle-btn" …>.

terminal
$ The console, fourth line
── what you'll see ──
toggle: after Send, the same button now says "Stop" and focus is on: body

Myth vs fact

Myth

Put aria-live="assertive" on the answer so it is always heard.

Fact

Assertive can interrupt and clear what the person is listening to. A streaming answer would interrupt itself constantly. MDN advises against it unless the interruption is imperative.

Myth

A div with a click handler and a pointer cursor is a button.

Fact

It cannot be focused or used with Enter and Space and has no role. Use <button>.

Myth

Accessibility is checked by running an automatic scanner.

Fact

Scanners find a part of the problems (missing labels, low contrast). They cannot tell you whether a stream is announced sensibly or whether focus is where it should be. Test with the keyboard and a screen reader.

Myth

Reduced motion means no animation at all.

Fact

It means no non-essential motion that could cause discomfort. A short fade or a static cursor is fine.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Name your regions. role="log" needs a name (aria-label="Conversation"), and the status element should be one per page, not one per message. Mounting a fresh region for each message risks missed announcements.

  • ▸

    Announce errors differently from progress: role="alert" for a failed request (it is assertive by definition), role="status" for "Answer ready". The first example's counting function is a good unit test: fail the build if a streamed answer produces more than a handful of announcements.

  • ▸

    Skip links, a visible focus ring (WCAG 1.4.11 asks 3:1 for the parts of controls needed to identify them) and sensible heading levels matter as much as the live region. A chat page still needs one <h1> and a way to jump from the transcript to the input.

  • ▸

    Zoom and reflow: WCAG 1.4.10 Reflow (Level AA) asks that content works at a width of 320 CSS pixels without scrolling in two directions. Check your chat at 400% zoom in a desktop browser; Topic 10.7 covers the long-word part.

Remember this

  1. 1

    The problem this solves: People who use a screen reader (software that reads the page aloud or to a braille display) cannot see the text grow. Your stream changes the page every few milliseconds. Done carelessly, the reader gets sixteen separate updates for a sixteen-word answer, interrupts itself, and the person hears noise. Done carefully, they hear "Mitra is answering" and, when it is done, the answer. Other people have needs here too: keyboard-only users, people sensitive to motion, and people reading code on a bright phone.

  2. 2

    Live regions. A live region is a part of the page whose changes assistive technology reports without the user moving to it. You make one with aria-live or with a role that implies it. MDN defines the values: off (the default; updates are not presented unless the user is focused there), polite (presented at the next graceful opportunity, such as the end of the current sentence) and assertive (presented immediately, possibly clearing the speech queue). MDN's warning: do not use assertive unless the interruption is imperative.

  3. 3

    Roles that carry it. role="log" is for information added in a meaningful order, such as a chat: MDN gives its implicit values as aria-live="polite" and aria-atomic="false" (only the changed part is announced), and it needs a name (aria-label="Conversation"). role="status" is for advisory information such as "Draft saved": implicit polite and atomic true (the whole region is presented). role="alert" is assertive and fits errors. WCAG 2.2 Success Criterion 4.1.3 Status Messages (Level AA) asks that status messages can be identified by software through a role or property so assistive technology can present them without moving focus.

  4. 4

    Why streaming breaks the default. If the growing answer lives inside a role="log", each added word is a change inside a live region. The first example counts them: the answer has 16 words and the naive version produces 16 announcements. That is the flood. Real screen readers merge, drop or queue updates differently, so the exact experience varies, but you should not depend on them coping.

  5. 5

    **Hold, then release: aria-busy.** MDN says that when a live region needs several changes before it makes sense, you set aria-busy="true" until loading is complete, then set it to false, so assistive technologies do not announce half-finished updates. The example's second pattern sets aria-busy while the answer streams and clears it at the end: one announcement. The caveat is real: how well each screen reader and browser honours aria-busy differs, so test with the ones your users use. If you forget to clear it, nothing is ever announced (Break it).

  6. 6

    A separate status line. The third pattern keeps the transcript out of the live region and uses a small role="status" element: "Mitra is answering." when the stream starts and "Answer ready." when it ends: two announcements. The answer itself is not read aloud automatically; the person moves to it when they choose. That is calm, but some users want the answer read to them. Offering both (a setting, or announcing the finished answer once) is a product decision.

  7. 7

    Create the region before you fill it. MDN notes that screen readers buffer content when a page loads, and changes to a live region that is added at the same moment as its content may not be noticed. Render the empty region early and update its text later. Do not mount a new region for every message.

  8. 8

    Focus management. Keyboard focus is where the next key press goes. After the person sends a message, focus should stay in the prompt box so they can keep typing. If a control that has focus is removed from the page, focus falls back to the page itself and the person loses their place. The second example shows it: replacing the Send button with a separate Stop element drops focus to body; one button that changes its label keeps it. When you open an editor, move focus into it; when it closes, return focus to the control that opened it. This is the idea behind WCAG 2.4.3 Focus Order (Level A).

  9. 9

    Everything reachable by keyboard. WCAG 2.1.1 Keyboard (Level A): all functionality must work with a keyboard. A <div onClick> styled like a button cannot be focused and does not respond to Enter or Space. The third example's Tab order includes the real <button> and skips the <div>. Use real buttons for Stop, Copy, Regenerate and thumbs, with visible text or an aria-label. Pressing Escape should close editors.

  10. 10

    Reduced motion. Many people set their system to reduce motion because animation can cause dizziness or nausea. The CSS media feature prefers-reduced-motion has two values, no-preference and reduce. Under reduce, stop the blinking cursor, replace smooth scrolling with a jump, and drop per-word fade-ins. window.matchMedia("(prefers-reduced-motion: reduce)").matches gives the same answer in JavaScript, and a change listener tells you if the person changes the setting while the page is open. WCAG 2.3.3 Animation from Interactions (Level AAA) says motion animation triggered by interaction can be disabled unless essential. It is a AAA criterion, but honouring the setting is cheap and kind.

  11. 11

    Contrast in code blocks. Syntax-highlight themes love pale comments. WCAG 1.4.3 Contrast (Minimum) (Level AA) needs at least 4.5:1 for normal text and 3:1 for large text, and the thresholds are not rounded (4.499:1 fails). WCAG 1.4.11 Non-text Contrast (Level AA) asks 3:1 for the visual parts of controls and states, such as a focus ring or the outline of the Stop button. The fourth example implements the WCAG relative-luminance and ratio formulas and tests six colour pairs, including a light-grey comment colour that fails at 2.85:1.

Explain it without notes

01

Why does a streaming answer inside role="log" flood a screen reader, and how do you avoid it?

02

When does keyboard focus get lost, and how does Mitra avoid that?

03

Why is a <div onClick> not an acceptable copy button?

04

What is prefers-reduced-motion and what does Mitra change when it is reduce?

Practice

01

Write a function announce(phase) that returns the text for the role="status" element for the phases idle, streaming, done, error and stopped, and log all five.

02

Add a check that fails if any <button> in the page has no text and no aria-label. Run it on a page with an icon-only button.

Trade-offs

  • ↔

    Holding a live region until the answer ends is calm but delays the first spoken word to the end of the stream. A status line plus manual reading is calm and immediate but not automatic. Offer a choice if your users need both.

  • ↔

    Honouring reduced motion costs a few lines and a second visual state to test. Skipping it is cheap for you and uncomfortable for some users.

Done when you can

  • I can explain aria-live values and the implicit values of role="log" and role="status".

  • I can stop a streaming answer from flooding a screen reader, and clear aria-busy on every exit path.

  • I can keep keyboard focus in sensible places when controls change or editors open and close.

  • I can make every control a real button and show that a keyboard reaches it.

  • I can respect prefers-reduced-motion and check text contrast against 4.5:1.