Accessible React: ARIA, Keyboard Navigation, and Screen Readers
Accessibility isn't a nice-to-have — it expands your user base, improves SEO, and avoids legal risk. Here's what actually matters.
Semantic HTML First
// WRONG: div soup
<div onClick={handleSubmit} className='btn'>Submit</div>
// RIGHT: semantic element — keyboard accessible by default
<button type='submit' onClick={handleSubmit}>Submit</button>
// Navigation
<nav aria-label='Main navigation'>
<ul>
<li><a href='/'>Home</a></li>
<li><a href='/about'>About</a></li>
</ul>
</nav>
// Sections
<main>
<h1>Page Title</h1>
<section aria-label='Features'>
<h2>Features</h2>
</section>
</main>
ARIA When HTML Isn't Enough
// Button that looks like a div
<div
role='button'
tabIndex={0}
aria-label='Close dialog'
onClick={onClose}
onKeyDown={(e) => e.key === 'Enter' && onClose()}
>
×
</div>
// Loading state
<button aria-busy={isLoading} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save'}
</button>
// Error messages
<input
id='email'
aria-describedby='email-error'
aria-invalid={!!errors.email}
/>
<p id='email-error' role='alert'>{errors.email}</p>
Focus Management
import { useRef, useEffect } from 'react';
function Modal({ isOpen, onClose, children }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Move focus into modal when it opens
closeButtonRef.current?.focus();
}
}, [isOpen]);
return (
<dialog
open={isOpen}
aria-modal='true'
aria-label='Settings'
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
<button ref={closeButtonRef} onClick={onClose}>Close</button>
{children}
</dialog>
);
}
Skip Navigation
// First element on page — lets keyboard users skip nav
<a
href='#main-content'
className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:p-4'
>
Skip to main content
</a>
<main id='main-content'>
{/* page content */}
</main>
Color Contrast
WCAG AA requires:
- Normal text: 4.5:1 contrast ratio
- Large text (18px+): 3:1
- UI components: 3:1
Check with: https://webaim.org/resources/contrastchecker/
Testing Tools
# axe-core — automated a11y testing
npm install --save-dev @axe-core/react
# eslint-plugin-jsx-a11y — catch issues in code
npm install --save-dev eslint-plugin-jsx-a11y
// In Vitest/Jest
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
test('Button has no a11y violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
shadcn/ui (used in the AI SaaS Starter Kit) is built on Radix UI — fully accessible primitives with ARIA and keyboard navigation pre-built. $99 at whoffagents.com.
Top comments (0)