Accessibility isn't a feature you add at the end — it's a quality signal that correlates with better code overall. Well-structured, accessible apps tend to have better semantics, better keyboard behavior, and better performance. Here's the practical implementation.
Semantic HTML First
Every accessibility problem that seems hard is often just a semantic HTML problem:
// Bad -- div soup, no semantics
<div className='header'>
<div className='nav'>
<div onClick={handleClick}>Home</div>
<div onClick={handleClick}>About</div>
</div>
</div>
// Good -- semantic HTML, keyboard accessible automatically
<header>
<nav aria-label='Main navigation'>
<ul>
<li><a href='/'>Home</a></li>
<li><a href='/about'>About</a></li>
</ul>
</nav>
</header>
Landmark elements (<header>, <nav>, <main>, <aside>, <footer>) give screen reader users a way to jump between sections.
ARIA: When and When Not To
ARIA attributes add semantic meaning when HTML alone isn't enough. But they don't add behavior — you must implement keyboard interaction yourself:
// Custom dropdown -- requires ARIA + keyboard handling
function Dropdown({ label, options, value, onChange }) {
const [isOpen, setIsOpen] = useState(false)
const listboxId = useId()
return (
<div>
<button
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-controls={listboxId}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => {
if (e.key === 'Escape') setIsOpen(false)
if (e.key === 'ArrowDown') { setIsOpen(true); /* focus first option */ }
}}
>
{value || label}
</button>
{isOpen && (
<ul id={listboxId} role='listbox' aria-label={label}>
{options.map(opt => (
<li
key={opt.value}
role='option'
aria-selected={value === opt.value}
onClick={() => { onChange(opt.value); setIsOpen(false) }}
tabIndex={0}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
)
}
The rule: if a native HTML element does what you need (<select>, <button>, <a>), use it. ARIA is for custom widgets.
Focus Management
// Dialog -- trap focus inside when open
import { useEffect, useRef } from 'react'
function Dialog({ isOpen, onClose, children }) {
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
// Focus the dialog on open
dialogRef.current?.focus()
// Trap focus
const focusable = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable?.[0] as HTMLElement
const last = focusable?.[focusable.length - 1] as HTMLElement
const trap = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey ? document.activeElement === first : document.activeElement === last) {
e.preventDefault()
;(e.shiftKey ? last : first)?.focus()
}
}
document.addEventListener('keydown', trap)
return () => document.removeEventListener('keydown', trap)
}, [isOpen])
return (
<div
ref={dialogRef}
role='dialog'
aria-modal='true'
tabIndex={-1}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
{children}
</div>
)
}
Color Contrast and Visual Design
WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text:
// Check contrast in your Tailwind config
// Gray-500 on white: 3.95:1 -- FAILS AA for normal text
// Gray-700 on white: 8.59:1 -- passes AAA
// Don't rely on color alone to convey information
// Bad: red = error (invisible to colorblind users)
// Good: red + icon + error message text
<div className='flex items-center gap-2 text-red-600'>
<AlertCircle className='w-4 h-4' aria-hidden='true' />
<span role='alert'>Email is invalid</span>
</div>
Automated Testing with axe
npm install -D @axe-core/react jest-axe
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Run axe in CI. Catch regressions before they reach users.
The AI SaaS Starter at whoffagents.com ships with semantic HTML structure, proper ARIA labels on all interactive elements, and axe-core integrated into the test suite. $99 one-time.
Top comments (0)