Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesHow do I make a streaming chat UI accessible (screen reader announcements, focus management, reduced motion)?
Building an accessible streaming chat UI means more than adding aria-label on a few elements. It requires a consistent strategy for screen reader announcements, predictable focus management, and honoring user preferences like reduced motion—all while messages stream in real time.
This guide walks through practical patterns and code examples you can use in any React-based chat, including setups with Assistant UI, the Vercel AI SDK, LangChain, or other LLM providers.
Core accessibility goals for streaming chat
When messages are streaming and the UI is constantly updating, aim for:
-
Perceivable
- Screen reader users are notified of new content without being overwhelmed.
- Visual users can see changes without distracting motion or jumps.
-
Operable
- Keyboard users can navigate messages, compose input, and use tools without traps.
- Focus doesn’t jump unexpectedly during streaming.
-
Understandable
- Announcements are concise and meaningful (e.g., “Assistant is typing” vs raw tokens).
- Conversation structure (who said what, and when) is conveyed clearly.
-
Robust
- Works across major screen readers (NVDA, JAWS, VoiceOver, TalkBack) and browsers.
- Respects OS and browser-level accessibility settings like reduced motion.
Semantic structure for chat UIs
A solid semantic foundation makes all the “live” behavior easier to reason about.
Recommended landmark and structure
Use landmarks and roles that communicate “this is a chat” and “this is a log of messages”:
<main aria-label="Chat with AI assistant">
<section
aria-label="Conversation"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-atomic="false"
>
{/* Message list */}
</section>
<form aria-label="Message composer">
{/* Input + send button */}
</form>
</main>
Key choices:
role="log": Announces new items added to a list over time without re-announcing old ones.aria-live="polite": Prevents interrupting ongoing announcements for every token; better for streaming.aria-relevant="additions": Only announce new messages, not minor edits like typing indicators.aria-atomic="false": Only the changed node is announced, not the entire log.
If your framework (like assistant-ui) already provides a chat container, align your custom markup with its structure rather than duplicating landmarks.
Screen reader announcements for streaming messages
Streaming is tricky: you want users to know the assistant is responding without flooding them with partial sentences.
Pattern: announce status, not every token
Instead of announcing every token, announce:
- Assistant started responding
- Assistant finished responding
- Optional summary after the final message (e.g., “Assistant response received.”)
Example: using an ARIA live region for status
function ChatLiveStatus({ isStreaming, lastSpeaker }) {
const message =
isStreaming && lastSpeaker === 'assistant'
? 'Assistant is responding…'
: '';
return (
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
Attach this to your chat UI and drive isStreaming from your LLM/adapter state (e.g., from Vercel AI SDK or assistant-ui’s state).
Pattern: announce new complete messages
When a full message is appended to the log, let screen readers know there’s something new, but do it through the role="log" container rather than a dedicated announcement for every message.
Example markup for a message item:
function ChatMessage({ role, content, id }) {
const label =
role === 'user' ? 'You said' : 'Assistant said';
return (
<article
role="article"
aria-label={label}
data-message-id={id}
>
<header className="sr-only">
{label}
</header>
<p>{content}</p>
</article>
);
}
Combined with a role="log" parent, screen readers will announce when messages are added.
Handling streaming text inside a single message
When messages stream token-by-token, the DOM for that message is being updated frequently. Avoid treating each token update as a live announcement.
Recommended approach
-
During streaming:
- Don’t put the streaming
<p>inside a separatearia-liveregion; just visually update. - Rely on “Assistant is responding…” status text.
- Don’t put the streaming
-
After streaming ends:
- Optionally move the final text into a non-live
articleinside the log. - Screen reader users can navigate to it when they’re ready.
- Optionally move the final text into a non-live
Pseudo-flow:
const [streamingMessage, setStreamingMessage] = useState('');
const [messages, setMessages] = useState([]);
async function handleAssistantResponse() {
setIsStreaming(true);
let finalText = '';
for await (const token of streamTokens()) {
finalText += token;
setStreamingMessage(finalText);
}
setIsStreaming(false);
setMessages(prev => [
...prev,
{ id: Date.now(), role: 'assistant', content: finalText },
]);
setStreamingMessage('');
}
The messages array is rendered inside your role="log" container; streamingMessage is rendered outside, without its own live region. This balances responsiveness with screen reader sanity.
Focus management patterns
Focus management is critical in an interactive, streaming chat environment.
General rules
- Never move focus automatically when new messages arrive.
- Users might be typing or reading older messages.
- Ensure the input is reachable via a logical tab order (
Tabinto input, then buttons). - Provide shortcuts for power users, but keep them optional and discoverable (e.g., “Press Alt+N to jump to latest message”).
When to move focus
Move focus only when:
- The user explicitly triggers an action that implies a focus change:
- Sending a message (if the input remains focused, do nothing).
- Opening a tool or attachment panel.
- Starting a new chat or clearing history.
Example: sending a message while keeping focus in the input:
function MessageComposer({ onSend }) {
const inputRef = React.useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const value = inputRef.current.value.trim();
if (!value) return;
onSend(value);
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="chat-input" className="sr-only">
Type your message
</label>
<textarea
id="chat-input"
ref={inputRef}
rows={2}
autoComplete="off"
/>
<button type="submit">Send</button>
</form>
);
}
Focus for error states and alerts
If a message fails to send or a tool call fails, use an ARIA alert and optionally move focus only if the error blocks further progress.
function ErrorBanner({ error }) {
if (!error) return null;
return (
<div role="alert">
{error}
</div>
);
}
Reserve forced focus (e.g., using ref.current.focus()) for critical blocking errors, not for benign notifications.
Reduced motion in streaming chat UIs
Users with motion sensitivity may have prefers-reduced-motion: reduce set at the OS or browser level. Your chat UI should honor that.
Avoid excessive animations for streaming
Common problem patterns:
- Sliding or fading each token or line into view.
- Auto-scrolling to bottom with smooth animation at every update.
- Pulsing “typing” indicators while messages stream.
Use CSS media queries to switch to simpler behavior for reduced motion users.
Example: disabling animations
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.typing-indicator {
animation: none !important;
}
}
Auto-scroll behavior
For most users, you’ll want to keep the chat scrolled to the bottom when new messages arrive—unless they’ve manually scrolled up.
For reduced motion:
- Use
element.scrollTop = element.scrollHeight(no smooth scroll) instead ofscrollIntoView({ behavior: 'smooth' }). - Condition this based on
prefers-reduced-motion.
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
function scrollToBottom(container) {
if (!container) return;
if (prefersReducedMotion) {
container.scrollTop = container.scrollHeight;
} else {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
});
}
}
Combine this with logic that detects if the user has scrolled up (e.g., only auto-scroll if they are already near the bottom).
Keyboard accessibility and shortcuts
A streaming chat UI should be fully usable with the keyboard alone.
Basic keyboard support
Ensure:
- Tab order: Input → send button → tool buttons → message list (if interactive).
- Visible focus: Customize focus outlines instead of removing them.
- Message navigation: If you allow interacting with messages (copy, retry, etc.), ensure each message or control is reachable with
Tab.
Example:
button:focus-visible,
textarea:focus-visible,
a:focus-visible {
outline: 2px solid #1d4ed8; /* accessible color */
outline-offset: 2px;
}
Optional shortcuts
You can enhance keyboard UX with shortcuts, but they must:
- Not conflict with common browser or screen reader shortcuts.
- Be documented (e.g., “Keyboard shortcuts” help dialog).
- Be easy to turn off (e.g., user setting).
Common patterns:
Ctrl+EnterorCmd+Enterto send.Alt+NorAlt+Endto jump to the latest message.Escto close tool panels or modals.
Accessible typing indicators and tool activity
Streaming chat often includes:
- “Assistant is typing…” indicators
- “Calling tool X…” status
- “Tool result loaded” messages
Typing indicators
Use text-based indicators that work for all users, not just animated dots.
function TypingIndicator({ isTyping }) {
if (!isTyping) return null;
return (
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
Assistant is typing…
</div>
);
}
Visually, you can show animated dots, but ensure the sr-only text exists for screen readers; tie the two to the same boolean state.
Tool calls and function execution
When your assistant triggers tools (e.g., search, database queries):
- Announce when a tool is running: “Searching your documents…”
- Announce when it completes: “Search results received.”
Example:
function ToolStatus({ toolName, status }) {
const message =
status === 'running'
? `${toolName} is running…`
: status === 'done'
? `${toolName} finished.`
: '';
if (!message) return null;
return (
<div role="status" aria-live="polite" className="sr-only">
{message}
</div>
);
}
Use role="status" for neutral, non-error notifications that should not interrupt the user.
Testing your streaming chat accessibility
Before shipping, test with:
Screen readers
- NVDA + Firefox/Chrome (Windows)
- JAWS + Chrome/Edge (Windows, if available)
- VoiceOver + Safari (macOS, iOS)
Basic test flows:
- Tab into chat input, type, and send a message.
- Confirm focus stays in the input.
- Listen for:
- “Assistant is responding…” or similar.
- Announcement of the final assistant message in the log.
- Navigate through previous messages with arrow keys or quick nav (e.g., VO keys).
Keyboard only
- Ensure every interactive element is reachable via
Tab. - Ensure focus never gets trapped or lost when new messages stream.
- Verify shortcuts don’t conflict with default browser actions.
Reduced motion
- Turn on “Reduce motion” at OS level (macOS, iOS, Windows, Android).
- Verify:
- No smooth scroll or animated auto-scroll.
- Typing indicators and transitions are not distracting.
- The chat remains fully usable.
Integrating with Assistant UI and other frameworks
If you’re using Assistant UI or similar component libraries:
-
Leverage built-in accessibility:
- Many frameworks already provide reasonable ARIA roles, keyboard handling, and live-region behavior.
- Read the docs to see how they handle streaming and announcements.
-
Extend, don’t duplicate:
- Add your own live regions for tool status and custom typing indicators.
- Wrap the provided chat components in landmarks (
<main>,<section>) if needed.
-
Respect framework state:
- Use the framework’s “isStreaming” or “status” flags to drive your
aria-livecontent. - Avoid creating separate, conflicting live regions for the same message stream.
- Use the framework’s “isStreaming” or “status” flags to drive your
Checklist: making your streaming chat UI accessible
Use this checklist while you implement:
Structure
- Conversation wrapped in a landmark (
<main>/<section>) withrole="log"andaria-live="polite". - Each message has clear labels (“You said”, “Assistant said”).
- Input and buttons have accessible names (
<label>,aria-label, or visible text).
Screen readers
- Status live region announces “Assistant is responding…” during streaming.
- Final messages are announced through the log when added, not token-by-token.
- Tool calls and errors have
role="status"orrole="alert"as appropriate.
Focus management
- Focus never moves automatically when new messages stream.
- Input remains focused after sending (unless user moves it).
- Critical errors can be focused and announced through
role="alert".
Reduced motion
-
prefers-reduced-motionis respected (no smooth scroll, minimal animations). - Typing indicators and transitions are subdued for reduced-motion users.
Keyboard
- All interactive elements are reachable via
Tab. - Focus is visually apparent (
:focus-visiblestyling). - Shortcuts are optional, documented, and don’t conflict with core browser/AT commands.
By following these patterns, you can ship a streaming chat experience that’s fast, production-ready, and inclusive for screen reader users, keyboard users, and anyone with motion sensitivity.