React chat accessibility: Inclusive AI interfaces
Accessibility is not a feature; it is a fundamental right. A chatbot that works only for sighted mouse users excludes roughly 15% of your audience—people using screen readers, keyboard navigation, or voice control. This article covers building an accessible React chatbot that meets WCAG 2.1 AA standards: semantic HTML, ARIA labels, keyboard shortcuts, screen reader announcements, and testing.
According to the WebAIM Million (2025), 98% of websites have accessibility errors. With intentional design, your chatbot can be in the 2% that does not.
Semantic HTML Structure
Start with proper semantic HTML. Do not use <div> for everything:
import React from 'react';
// Bad: non-semantic
<div className="chat-container">
<div className="messages">
<div className="message">User: Hello</div>
<div className="message">Assistant: Hi</div>
</div>
<div className="input">
<input type="text" />
<div className="button">Send</div>
</div>
</div>
// Good: semantic
<section aria-label="Chat conversation">
<main>
<article role="log" aria-live="polite" aria-label="Chat messages">
<article>
<h2 className="sr-only">User message</h2>
<p>Hello</p>
</article>
<article>
<h2 className="sr-only">Assistant message</h2>
<p>Hi</p>
</article>
</article>
<form aria-label="Send message">
<label htmlFor="message-input">Message:</label>
<input
id="message-input"
type="text"
placeholder="Type your message..."
aria-describedby="send-help"
/>
<button type="submit">Send</button>
<span id="send-help" className="sr-only">Press Enter to send, or click Send</span>
</form>
</main>
</section>
Key semantic elements:
<section>groups related content.<main>marks the primary content.<article>marks distinct messages.<form>wraps the input area.<label>associates with<input>.<button>is native and keyboard-accessible.
ARIA Labels and Live Regions
ARIA (Accessible Rich Internet Applications) provides metadata for assistive technologies:
function AccessibleChat() {
const [messages, setMessages] = React.useState([]);
const [isStreaming, setIsStreaming] = React.useState(false);
const handleSendMessage = async (userMessage) => {
setMessages((prev) => [
...prev,
{ role: 'user', content: userMessage, id: `msg_${Date.now()}` },
]);
setIsStreaming(true);
// ... API call ...
setIsStreaming(false);
};
return (
<div className="chat-wrapper">
<header>
<h1>Accessible AI Chatbot</h1>
<p>Ask questions and get real-time responses</p>
</header>
<main>
{/* Live region: announces new messages to screen reader users */}
<section
role="log"
aria-live="polite"
aria-atomic="false"
aria-label="Chat messages"
className="messages"
style={{ height: '400px', overflowY: 'auto' }}
>
{messages.map((msg) => (
<article
key={msg.id}
className={`message message-${msg.role}`}
aria-label={`${msg.role === 'user' ? 'Your' : 'Assistant'} message`}
>
<h2 className="sr-only">{msg.role}</h2>
<p>{msg.content}</p>
</article>
))}
{/* Announce typing indicator */}
{isStreaming && (
<div
role="status"
aria-live="polite"
aria-label="Assistant is responding"
className="typing-indicator"
>
<span className="sr-only">Assistant is typing a response</span>
<span aria-hidden="true">...</span>
</div>
)}
</section>
{/* Input form with ARIA descriptions */}
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.target.elements.messageInput;
if (input.value.trim()) {
handleSendMessage(input.value);
input.value = '';
}
}}
aria-label="Send message form"
>
<div>
<label htmlFor="message-input" className="sr-only">
Message:
</label>
<input
id="message-input"
name="messageInput"
type="text"
placeholder="Type your message... (press Enter or click Send)"
aria-describedby="input-help"
disabled={isStreaming}
autoComplete="off"
/>
<small id="input-help" className="sr-only">
You can type a message and press Enter to send it, or click the Send button. The
assistant will respond to your message.
</small>
</div>
<button
type="submit"
disabled={isStreaming}
aria-busy={isStreaming}
aria-label={isStreaming ? 'Waiting for response' : 'Send message'}
>
{isStreaming ? 'Waiting...' : 'Send'}
</button>
</form>
</main>
<style>{`
/* Screen reader only text */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Focus visible for keyboard users */
input:focus-visible,
button:focus-visible {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: more) {
input,
button {
border: 2px solid currentColor;
}
}
`}</style>
</div>
);
}
export default AccessibleChat;
Key ARIA attributes:
role="log": announces messages as they arrive (for screen readers).aria-live="polite": update the region when messages are added.aria-label: describe elements that have no visible text.aria-describedby: link input fields to helper text.aria-atomic="false": only announce new content, not the entire region.aria-busy: indicate loading state.aria-hidden="true": hide decorative elements (like...) from screen readers.
Keyboard Navigation
Ensure full keyboard accessibility:
function KeyboardAccessibleInput() {
const [messages, setMessages] = React.useState([]);
const messageListRef = React.useRef(null);
const inputRef = React.useRef(null);
const handleKeyDown = (e) => {
// Alt+/ to focus the input (common chat shortcut)
if ((e.altKey || e.metaKey) && e.key === '/') {
e.preventDefault();
inputRef.current?.focus();
}
// Ctrl+Enter or Cmd+Enter to send
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
const input = e.currentTarget;
if (input.value.trim()) {
handleSendMessage(input.value);
input.value = '';
}
}
// Arrow up: edit previous message (if supported)
if (e.key === 'ArrowUp' && e.target.value === '') {
e.preventDefault();
// Load previous message into input (optional feature)
}
};
React.useEffect(() => {
// Auto-focus input on mount
inputRef.current?.focus();
}, []);
return (
<input
ref={inputRef}
type="text"
placeholder="Message (Alt+/ to focus, Ctrl+Enter to send)"
onKeyDown={handleKeyDown}
/>
);
}
Keyboard shortcuts should be documented and optional (not the only way to interact).
Color Contrast and Visual Design
Ensure sufficient color contrast for readability:
<style>{`
/* WCAG AA: 4.5:1 contrast for normal text, 3:1 for large text */
.message-user {
background-color: #0066cc; /* #0066cc on white = 8.9:1 */
color: #ffffff;
}
.message-assistant {
background-color: #f0f0f0; /* #f0f0f0 on white = 7.6:1 */
color: #000000;
}
button {
background-color: #0066cc;
color: #ffffff;
border: 2px solid #0066cc;
padding: 12px 24px;
font-size: 16px; /* Minimum 16px for mobile readability */
}
button:focus-visible {
outline: 3px solid #ffcc00;
outline-offset: 2px;
}
/* High contrast mode */
@media (prefers-contrast: more) {
.message-user {
border: 2px solid #000;
}
button {
border-width: 3px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.message-assistant {
background-color: #2a2a2a;
color: #ffffff;
}
body {
background-color: #1a1a1a;
color: #ffffff;
}
}
`}</style>
Test contrast using tools like WebAIM Contrast Checker or axe DevTools.
Testing for Accessibility
Use automated and manual testing:
# Install accessibility testing tools
npm install --save-dev axe-core jest-axe
# Run tests
npx axe http://localhost:3000
In your test file:
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Chat from './Chat';
expect.extend(toHaveNoViolations);
test('Chat component has no accessibility violations', async () => {
const { container } = render(<Chat />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Also, test manually with a screen reader:
- NVDA (Windows, free)
- JAWS (Windows, paid)
- VoiceOver (macOS/iOS, built-in)
- TalkBack (Android, built-in)
WCAG 2.1 AA Checklist for Chat
| Criterion | How to test | Status |
|---|---|---|
| 1.4.3 Contrast | Use WebAIM Contrast Checker; aim for 4.5:1 | ✓ |
| 2.1.1 Keyboard | Tab through entire chat; ensure all interactions work | ✓ |
| 2.1.2 No keyboard trap | Tab should never get stuck; Escape should close modals | ✓ |
| 2.4.7 Focus visible | Focus indicator visible on all interactive elements | ✓ |
| 3.2.4 Consistent navigation | Navigation elements in same place on every view | ✓ |
| 4.1.3 Status messages | Use aria-live for dynamic messages | ✓ |
Key Takeaways
- Use semantic HTML (
<section>,<main>,<button>,<label>) as the foundation. - Use ARIA live regions (
aria-live="polite") to announce new messages to screen readers. - Ensure keyboard navigation: Tab moves focus, Enter/Space activates, Escape closes.
- Maintain 4.5:1 color contrast for normal text; 3:1 for large text.
- Test with real screen readers and accessibility tools like axe.
Frequently Asked Questions
What is the difference between aria-live="polite" and "assertive"?
polite waits for the user to finish their current action before announcing. assertive interrupts immediately. For chat, polite is usually better; users do not want interruptions.
How do I hide decorative elements from screen readers?
Use aria-hidden="true" or the CSS .sr-only class (absolute positioning with 1px dimensions).
Should typing indicators be announced to screen reader users?
Yes. Use role="status" and aria-live="polite" to say "Assistant is responding". Provide a text alternative hidden with .sr-only.
What if the AI generates content with images or special characters?
Use alt text for images (<img alt="description" />). For special characters, use ARIA labels to explain them.
How do I test accessibility without special tools?
Use your keyboard to navigate: Tab through all elements, use Enter/Space to activate, arrow keys in lists. If you can do everything without a mouse, it is a good start.