Accessibility Audit & Fix
Help users identify and fix accessibility issues in React/Next.js components with Tailwind CSS. The goal is practical, actionable guidance — not an exhaustive WCAG spec dump.
Quick Audit Checklist
When auditing a component or page, check these areas in order. Each section below explains what to look for and how to fix it.
- Semantic HTML — are the right elements used?
- Keyboard navigation — can everything be reached and operated with keyboard alone?
- ARIA attributes — are labels, roles, and states correct?
- Color and contrast — does it meet minimum contrast ratios?
- Focus management — is focus visible and logical?
- Screen reader experience — does it make sense when read aloud?
- Motion and animation — is reduced motion respected?
Semantic HTML
Using the right HTML elements is the single highest-impact accessibility improvement because it gives you keyboard support, screen reader announcements, and proper roles for free.
Common mistakes and fixes
| Instead of... | Use... | Why |
|---|---|---|
<div onClick> |
<button> |
Buttons are focusable, respond to Enter/Space, announced as "button" |
<div> for navigation |
<nav> |
Screen readers can jump to nav landmarks |
<div> for main content |
<main> |
Landmark navigation — users can skip to main content |
<span> for headings |
<h1>–<h6> |
Screen readers build a page outline from headings |
<div> for lists |
<ul>/<ol> |
Screen readers announce "list, 5 items" |
<a> without href |
<button> |
Links navigate, buttons perform actions |
Heading hierarchy
Headings should form a logical outline. Don't skip levels for styling — use Tailwind to style any heading however you want:
// Bad — skips h2, uses headings for styling
<h1>Dashboard</h1>
<h3 className="text-lg">Recent Activity</h3>
// Good — proper hierarchy, styled independently
<h1 className="text-2xl font-bold">Dashboard</h1>
<h2 className="text-lg font-semibold">Recent Activity</h2>
Keyboard Navigation
Every interactive element must be operable with keyboard alone. This matters for users who can't use a mouse — people with motor disabilities, power users, and anyone with a broken trackpad.
Built-in keyboard support
Native HTML elements handle keyboard interaction automatically:
<button>— Enter and Space to activate<a href>— Enter to follow<input>,<select>,<textarea>— full keyboard support<details>— Enter/Space to toggle
Custom interactive elements
When building custom components (dropdowns, modals, tabs), implement these keyboard patterns:
Dropdown menu:
// Arrow keys to navigate, Enter to select, Escape to close
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
focusNext()
break
case 'ArrowUp':
e.preventDefault()
focusPrevious()
break
case 'Escape':
closeMenu()
triggerRef.current?.focus() // return focus to trigger
break
case 'Enter':
case ' ':
e.preventDefault()
selectCurrent()
break
}
}
Tab panels: Arrow keys move between tabs, Tab moves to panel content.
Modals: Trap focus inside the modal, Escape to close, return focus to trigger on close.
Tab order
Elements are focusable in DOM order by default. Avoid tabIndex values greater than 0 — they create unpredictable tab order. Use only:
tabIndex={0}— add to tab order (for custom interactive elements)tabIndex={-1}— programmatically focusable but not in tab order (for focus management)
ARIA Attributes
ARIA fills the gaps when HTML semantics aren't enough. The first rule: if a native HTML element does the job, use that instead of ARIA.
Essential patterns
Buttons with icons only (no visible text):
<button aria-label="Close dialog" className="p-2 rounded hover:bg-gray-100">
<XIcon className="w-5 h-5" />
</button>
Form inputs:
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-describedby="email-hint email-error"
aria-invalid={!!error}
/>
<p id="email-hint" className="text-sm text-gray-500">We'll never share your email</p>
{error && <p id="email-error" className="text-sm text-red-600" role="alert">{error}</p>}
</div>
Loading states:
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? <Spinner /> : <Content />}
</div>
Toggle buttons:
<button
aria-pressed={isActive}
=> setIsActive(!isActive)}
className={isActive ? 'bg-blue-600 text-white' : 'bg-gray-100'}
>
Dark mode
</button>
Expandable sections:
<button
aria-expanded={isOpen}
aria-controls="section-content"
=> setIsOpen(!isOpen)}
>
{isOpen ? '▼' : '▶'} Details
</button>
<div id="section-content" hidden={!isOpen}>
Expanded content here
</div>
Live regions
Use aria-live to announce dynamic content changes to screen readers:
aria-live="polite"— announces when the user is idle (status updates, success messages)aria-live="assertive"— interrupts immediately (errors, urgent alerts)role="status"— implicitaria-live="polite"(search results count, form status)role="alert"— implicitaria-live="assertive"(error messages)
// Search results count — announced politely
<p role="status" className="text-sm text-gray-500">
{results.length} results found
</p>
// Form error — announced immediately
{error && (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
)}
Color and Contrast
Minimum contrast ratios (WCAG AA)
- Normal text (under 18px / 14px bold): 4.5:1 contrast ratio
- Large text (18px+ / 14px+ bold): 3:1 contrast ratio
- UI components (borders, icons, focus indicators): 3:1 contrast ratio
Common Tailwind contrast issues
Some Tailwind color combinations don't meet contrast requirements:
| Combination | Ratio | Passes? |
|---|---|---|
text-gray-400 on white |
~3.1:1 | Fails for normal text |
text-gray-500 on white |
~4.6:1 | Passes AA |
text-gray-600 on white |
~5.7:1 | Passes AA |
text-white on bg-blue-400 |
~3.0:1 | Fails for normal text |
text-white on bg-blue-600 |
~5.0:1 | Passes AA |
Don't rely on color alone
Always pair color with another indicator — icons, text labels, patterns, or underlines:
// Bad — only color indicates status
<span className={status === 'error' ? 'text-red-500' : 'text-green-500'}>
{status}
</span>
// Good — color + icon
<span className={status === 'error' ? 'text-red-600' : 'text-green-600'}>
{status === 'error' ? '✕' : '✓'} {status}
</span>
Focus Management
Visible focus indicators
Tailwind's focus:ring utilities provide visible focus. Make sure interactive elements have clear focus styles:
<button className="px-4 py-2 bg-blue-600 text-white rounded
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Save
</button>
<a href="/about" className="text-blue-600 underline
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:rounded">
About us
</a>
Never remove focus outlines without providing an alternative visual indicator.
Focus trapping in modals
When a modal opens, focus should be trapped inside it until it closes:
"use client"
import { useEffect, useRef } from 'react'
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen || !containerRef.current) return
const container = containerRef.current
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
firstElement?.focus()
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
container.addEventListener('keydown', handleKeyDown)
return () => container.removeEventListener('keydown', handleKeyDown)
}, [isOpen])
return containerRef
}
Skip links
Add a skip-to-content link as the first focusable element on the page:
// In your root layout
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-blue-600
focus:rounded focus:shadow-lg"
>
Skip to main content
</a>
// On your main content
<main id="main-content" tabIndex={-1}>
{children}
</main>
Screen Reader Experience
Hide decorative elements
// Decorative images — hide from screen readers
<img src="/decoration.svg" alt="" aria-hidden="true" />
// Icons next to text — hide the icon, the text is sufficient
<button>
<TrashIcon aria-hidden="true" className="w-4 h-4 mr-2" />
Delete
</button>
// Visually hidden text for additional context
<button>
Delete
<span className="sr-only">user John Smith</span>
</button>
Tailwind's sr-only class
Use sr-only to provide text that's invisible visually but read by screen readers:
// Table actions with context
<td>
<button>
Edit<span className="sr-only"> product {product.name}</span>
</button>
</td>
Motion and Animation
Respect prefers-reduced-motion
Some users experience motion sickness or vestibular disorders. Use Tailwind's motion-reduce variant:
<div className="transition-transform duration-300 motion-reduce:transition-none
hover:scale-105 motion-reduce:hover:scale-100">
Card content
</div>
For custom animations:
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
// Use in animation logic
const duration = prefersReducedMotion ? 0 : 300
Images
Always provide alt text
// Informative image — describe what it shows
<img src="/chart.png" alt="Revenue grew 23% from Q3 to Q4 2025" />
// Decorative image — empty alt
<img src="/wave-divider.svg" alt="" />
// Complex image — use aria-describedby for longer descriptions
<figure>
<img src="/org-chart.png" alt="Organization chart" aria-describedby="org-desc" />
<figcaption id="org-desc">
The CEO reports to the board. Three VPs report to the CEO: VP Engineering, VP Product, VP Sales.
</figcaption>
</figure>
Testing Accessibility
Manual checks
- Keyboard-only test — unplug your mouse, navigate the entire page with Tab, Shift+Tab, Enter, Space, Escape, Arrow keys
- Screen reader test — use NVDA (Windows, free) or VoiceOver (macOS, built-in) to navigate the page
- Zoom test — zoom to 200%, verify nothing overlaps or becomes unusable
Automated checks
# Install axe-core for automated checks
npm install -D @axe-core/react
# Or use the browser extension:
# - axe DevTools (Chrome/Firefox)
# - WAVE (Chrome/Firefox)
In development:
// Only in development — add to your root layout or app entry
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then(axe => {
axe.default(React, ReactDOM, 1000)
})
}
This logs accessibility violations directly in the browser console during development.