Accessibility is not a cleanup task anymore. These 6 React patterns fix the failures that usually show up in audits, lawsuits, and senior frontend interviews.
1. Replace placeholder-only inputs with real labels
Placeholder text is not a label. It disappears on input, it is inconsistent for screen readers, and it fails fast in real forms.
Before
export function SearchBar() {
return (
<input
type="search"
placeholder="Search jobs..."
className="border rounded px-3 py-2"
/>
)
}
After
export function SearchBar() {
return (
<div>
<label htmlFor="job-search" className="sr-only">
Search jobs
</label>
<input
id="job-search"
type="search"
placeholder="Search jobs..."
aria-label="Search jobs by title, company, or technology"
className="border rounded px-3 py-2"
/>
</div>
)
}
This fixes accessible naming immediately. The input now works for screen readers, survives typing, and gives you a stable target for form testing.
2. Stop building buttons out of divs
A clickable div looks harmless until you try to tab to it. Native HTML already gives you keyboard support, focus, and semantics for free.
Before
export function ApplyButton({ onApply }: { onApply: () => void }) {
return (
<div
onClick={onApply}
className="inline-flex cursor-pointer rounded bg-blue-600 px-4 py-2 text-white"
>
Apply now
</div>
)
}
After
export function ApplyButton({ onApply }: { onApply: () => void }) {
return (
<button
type="button"
onClick={onApply}
className="inline-flex rounded bg-blue-600 px-4 py-2 text-white"
>
Apply now
</button>
)
}
The after version works with Enter, Space, Tab, and screen readers without extra JavaScript. That usually removes 4 or 5 lines of ARIA patchwork per component.
3. Reset focus after client-side navigation
SPA navigation breaks the browser behavior users rely on. When the route changes but focus stays in the old nav menu, screen reader users have no clue the page changed.
Before
'use client'
import { usePathname } from 'next/navigation'
export function RouteShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<>
<Header />
<main>{children}</main>
</>
)
}
After
'use client'
import { useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
export function RouteShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const liveRef = useRef<HTMLDivElement>(null)
const mainRef = useRef<HTMLElement>(null)
useEffect(() => {
if (liveRef.current) {
liveRef.current.textContent = ''
requestAnimationFrame(() => {
if (liveRef.current) liveRef.current.textContent = document.title
})
}
mainRef.current?.focus()
}, [pathname])
return (
<>
<div
ref={liveRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<Header />
<main id="main-content" ref={mainRef} tabIndex={-1}>
{children}
</main>
</>
)
}
Now route changes are announced and focus lands on the new content, not the old trigger. This pattern also fits nicely with broader JavaScript testing workflows using Jest and Playwright when you want accessibility checks in CI.
4. Announce async UI updates with live regions
Search results, toasts, and loading states are invisible to assistive tech unless you explicitly announce them. Visually obvious is not the same as programmatically exposed.
Before
export function JobSearch({ results, loading }: {
results: { id: string; title: string }[]
loading: boolean
}) {
return (
<section>
{loading && <Spinner />}
<ul>
{results.map(job => (
<li key={job.id}>{job.title}</li>
))}
</ul>
</section>
)
}
After
export function JobSearch({ results, loading }: {
results: { id: string; title: string }[]
loading: boolean
}) {
return (
<section>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{loading ? 'Loading search results' : `${results.length} jobs found`}
</div>
{loading ? (
<div role="status">
<span className="sr-only">Loading</span>
<Spinner />
</div>
) : (
<ul aria-label={`${results.length} job results`}>
{results.map(job => (
<li key={job.id}>{job.title}</li>
))}
</ul>
)}
</section>
)
}
This is the difference between a UI that looks responsive and one that actually communicates state. It also makes async behavior much easier to reason about in tests.
5. Connect form errors to the field that caused them
Most React forms render an error message near the input and call it done. Screen readers need the field, the invalid state, and the message tied together.
Before
export function EmailField({ error }: { error?: string }) {
return (
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" className="border rounded px-3 py-2" />
{error && <p className="text-red-600">{error}</p>}
</div>
)
}
After
export function EmailField({ error }: { error?: string }) {
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? 'email-error' : 'email-hint'}
className="border rounded px-3 py-2"
/>
{error ? (
<p id="email-error" role="alert" className="text-red-600">
{error}
</p>
) : (
<p id="email-hint" className="text-sm text-gray-600">
We will only use this for application updates.
</p>
)}
</div>
)
}
The input now exposes its full validation state. Screen readers announce the error in context instead of dumping disconnected red text somewhere below the form.
6. Add skip links before you ship the layout
Keyboard users should not tab through the whole header on every page. A skip link is tiny, boring, and one of the highest ROI accessibility fixes you can make.
Before
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<header>{/* nav, logo, filters */}</header>
<main>{children}</main>
</>
)
}
After
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2"
>
Skip to main content
</a>
<header>{/* nav, logo, filters */}</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
)
}
This takes about 2 minutes to add and saves keyboard users dozens of keystrokes per page. It is also the kind of detail that makes audits feel clean instead of chaotic.
These patterns are not advanced. That is exactly the point. Most accessibility failures come from basic component decisions made too early and never revisited. Fix these 6 in your design system first, then wire axe and keyboard tests into CI, and your React app will be in a much better place before the first audit shows up.
Top comments (0)