Web accessibility isn't optional—it's essential. In this beginner-friendly guide, you'll learn how to build accessible React components using TypeScript. Whether you're new to accessibility or React, this practical tutorial will help you create inclusive web applications that everyone can use.
Why Accessibility Matters in React
Over 1 billion people worldwide live with some form of disability. Making your React apps accessible means:
- Wider audience - Your app works for everyone, including people using screen readers or keyboard navigation
- Better SEO - Search engines favor accessible websites
- Legal compliance - Many countries require web accessibility (WCAG guidelines)
- Improved UX - Accessible design benefits all users
Setting Up TypeScript for Accessible Components
TypeScript helps catch accessibility issues at compile time. Here's how to type your accessible props:
// types.ts
import { AriaAttributes, HTMLAttributes } from 'react';
// Extend HTML attributes with ARIA props
interface AccessibleProps extends HTMLAttributes<HTMLElement>, AriaAttributes {
className?: string;
}
// Button with accessible props
interface ButtonProps extends AccessibleProps {
variant?: 'primary' | 'secondary';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
Building an Accessible Button Component
Let's start with the most common component—a button. Here's how to make it fully accessible:
// Button.tsx
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
ariaLabel?: string;
type?: 'button' | 'submit' | 'reset';
}
const Button: React.FC<ButtonProps> = ({
children,
onClick,
variant = 'primary',
disabled = false,
ariaLabel,
type = 'button',
...props
}) => {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
aria-disabled={disabled}
className={`btn btn--${variant} ${disabled ? 'btn--disabled' : ''}`}
{...props}
>
{children}
</button>
);
};
export default Button;
// Usage
<Button onClick={handleSubmit} ariaLabel="Submit form">
Submit
</Button>
Key Accessibility Features:
-
Semantic HTML - Using real
<button>instead of<div> - aria-label - Screen reader description
- aria-disabled - Announces disabled state
- type attribute - Prevents form submission bugs
Creating an Accessible Modal Dialog
Modals require special attention for accessibility. Here's a complete implementation:
// Modal.tsx
import React, { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Focus trap - keep focus inside modal
useEffect(() => {
if (isOpen) {
// Focus close button when modal opens
closeButtonRef.current?.focus();
// Prevent background scroll
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [isOpen]);
// Close on Escape key
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
ref={modalRef}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close modal"
className="modal-close"
>
✕
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
export default Modal;
Accessibility Features Explained:
- role="dialog" - Tells screen readers this is a dialog
- aria-modal="true" - Indicates modal behavior
- aria-labelledby - Links to the modal title
- Focus management - Automatically focuses close button
- Escape key - Standard keyboard shortcut to close
- Body scroll lock - Prevents background scrolling
Building an Accessible Form
Forms are crucial for accessibility. Here's how to create an input component:
// Input.tsx
import React, { useId } from 'react';
interface InputProps {
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
error?: string;
required?: boolean;
placeholder?: string;
helpText?: string;
}
const Input: React.FC<InputProps> = ({
label,
type = 'text',
value,
onChange,
error,
required = false,
placeholder,
helpText,
}) => {
const id = useId();
const errorId = `${id}-error`;
const helpId = `${id}-help`;
return (
<div className="form-field">
<label htmlFor={id} className="form-label">
{label}
{required && <span aria-label="required"> *</span>}
</label>
{helpText && (
<p id={helpId} className="form-help">
{helpText}
</p>
)}
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={
error ? errorId : helpText ? helpId : undefined
}
className={`form-input ${error ? 'form-input--error' : ''}`}
/>
{error && (
<p id={errorId} className="form-error" role="alert">
{error}
</p>
)}
</div>
);
};
export default Input;
// Usage
<Input
label="Email Address"
type="email"
value={email}
onChange={setEmail}
required
helpText="We'll never share your email"
error={emailError}
/>
Form Accessibility Features:
-
Unique IDs - Using
useId()for proper label association - aria-required - Announces required fields
- aria-invalid - Indicates validation errors
- aria-describedby - Links help text and errors
- role="alert" - Screen readers announce errors immediately
Accessible Navigation with TypeScript
Navigation requires keyboard support and proper ARIA attributes:
// Navigation.tsx
import React, { useState } from 'react';
interface NavItem {
label: string;
href: string;
current?: boolean;
}
interface NavigationProps {
items: NavItem[];
}
const Navigation: React.FC<NavigationProps> = ({ items }) => {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (
event: React.KeyboardEvent,
index: number
) => {
const { key } = event;
if (key === 'ArrowRight') {
event.preventDefault();
const nextIndex = (index + 1) % items.length;
setActiveIndex(nextIndex);
document.getElementById(`nav-item-${nextIndex}`)?.focus();
} else if (key === 'ArrowLeft') {
event.preventDefault();
const prevIndex = (index - 1 + items.length) % items.length;
setActiveIndex(prevIndex);
document.getElementById(`nav-item-${prevIndex}`)?.focus();
} else if (key === 'Home') {
event.preventDefault();
setActiveIndex(0);
document.getElementById('nav-item-0')?.focus();
} else if (key === 'End') {
event.preventDefault();
const lastIndex = items.length - 1;
setActiveIndex(lastIndex);
document.getElementById(`nav-item-${lastIndex}`)?.focus();
}
};
return (
<nav aria-label="Main navigation">
<ul role="menubar" className="nav-list">
{items.map((item, index) => (
<li key={item.href} role="none">
<a
id={`nav-item-${index}`}
href={item.href}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
aria-current={item.current ? 'page' : undefined}
onKeyDown={(e) => handleKeyDown(e, index)}
className="nav-link"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
);
};
export default Navigation;
Common ARIA Attributes Explained
| Attribute | Purpose | Example |
|---|---|---|
aria-label |
Provides accessible name | aria-label="Close menu" |
aria-labelledby |
References another element for label | aria-labelledby="modal-title" |
aria-describedby |
References description text | aria-describedby="help-text" |
aria-expanded |
Indicates if element is expanded | aria-expanded="true" |
aria-hidden |
Hides from screen readers | aria-hidden="true" |
aria-live |
Announces dynamic content | aria-live="polite" |
Testing Your Accessible Components
Here's how to test accessibility:
1. Keyboard Testing
- Tab - Navigate through interactive elements
- Shift + Tab - Navigate backwards
- Enter/Space - Activate buttons
- Escape - Close modals/dropdowns
- Arrow keys - Navigate lists/menus
2. Screen Reader Testing
// Install React Testing Library
npm install --save-dev @testing-library/react @testing-library/jest-dom
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import Button from './Button';
describe('Button Accessibility', () => {
test('has accessible role', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: 'Click me' });
expect(button).toBeInTheDocument();
});
test('announces disabled state', () => {
render(<Button disabled>Submit</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
});
3. Automated Tools
// Install axe-core
npm install --save-dev @axe-core/react
// index.tsx (development only)
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then(axe => {
axe.default(React, ReactDOM, 1000);
});
}
Best Practices Checklist
-
✅ Use semantic HTML (
<button>,<nav>,<main>) - ✅ Always provide text alternatives for images
- ✅ Ensure sufficient color contrast (4.5:1 minimum)
- ✅ Make all functionality keyboard accessible
- ✅ Provide visible focus indicators
- ✅ Use ARIA attributes when HTML semantics aren't enough
- ✅ Test with real screen readers (NVDA, VoiceOver)
- ✅ Don't rely on color alone to convey information
- ✅ Ensure forms have proper labels and error messages
- ✅ Implement skip links for navigation
Common Mistakes to Avoid
❌ Don't Use Divs as Buttons
// Bad
<div onClick={handleClick}>Click me</div>
// Good
<button onClick={handleClick}>Click me</button>
❌ Don't Use Placeholder as Label
// Bad
<input placeholder="Enter your email" />
// Good
<label htmlFor="email">Email</label>
<input id="email" placeholder="you@example.com" />
❌ Don't Forget Alt Text
// Bad
<img src="logo.png" />
// Good
<img src="logo.png" alt="Company logo" />
Resources for Learning More
- WCAG Guidelines - Official accessibility standards
- React Aria - Accessible component library from Adobe
- Reach UI - Accessible React components
- axe DevTools - Browser extension for testing
- WebAIM - Articles and testing tools
Key Takeaways
- Accessibility benefits everyone, not just users with disabilities
- TypeScript helps catch accessibility issues at compile time
- Use semantic HTML before reaching for ARIA attributes
- Test with keyboard navigation and screen readers
- Focus management is critical for modals and dynamic content
- Provide clear labels and error messages for forms
- Automated tools are helpful, but manual testing is essential
Building accessible React components doesn't have to be complicated. Start with semantic HTML, add ARIA attributes when needed, and always test with real users. Your future users—and your SEO rankings—will thank you!
💡 Pro tip: Install a screen reader (NVDA for Windows, VoiceOver for Mac) and navigate your components with it. This hands-on experience is invaluable!