Skip to main content

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

CriterionHow to testStatus
1.4.3 ContrastUse WebAIM Contrast Checker; aim for 4.5:1
2.1.1 KeyboardTab through entire chat; ensure all interactions work
2.1.2 No keyboard trapTab should never get stuck; Escape should close modals
2.4.7 Focus visibleFocus indicator visible on all interactive elements
3.2.4 Consistent navigationNavigation elements in same place on every view
4.1.3 Status messagesUse 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.

Further Reading