Next.js Accessibility: Getting to 100 and Why That Is Not Enough
Key takeaways
- Automated tools catch roughly a third of accessibility issues. The rest needs manual testing.
- Client-side routing breaks focus management — this is the most common React-specific failure.
- Skip links, focus indicators and semantic landmarks cost almost nothing to implement.
- Overlay widgets do not fix accessibility and are unpopular with the people they claim to help.
- The keyboard-only test takes five minutes and finds more than any audit tool.
Getting Lighthouse accessibility to 100 in a Next.js app takes an afternoon. Making the app actually usable with a keyboard and a screen reader takes longer, and the score will not tell you when you are done.
This article covers both: the fixes that get the number up, and the ones that matter after that. Focus is on React and Next.js specifics — the framework introduces failure modes that plain HTML does not have.
Contents
What automated audits actually measure
Lighthouse, axe and similar tools test what can be tested programmatically: contrast ratios, missing alt attributes, form labels, ARIA misuse, heading order.
Published estimates from the tooling vendors themselves put automated coverage at roughly 30 to 40 percent of WCAG success criteria. The remainder requires human judgement:
- Is the alt text meaningful, or just present?
- Does keyboard focus follow a logical order?
- Does the page announce changes when content updates dynamically?
- Can a screen reader user complete the primary task?
A score of 100 means no detectable violations. It does not mean the site is accessible. Both statements are true simultaneously, and treating the first as the second is the most common mistake in this area.
The React-specific problems
Focus management on route change
This is the big one, and it is invisible in automated audits.
In a traditional multi-page site, clicking a link loads a new document and the browser resets focus to the top. Screen readers announce the new page title. With client-side routing, none of that happens: the URL changes, the DOM updates, and focus stays wherever it was — often on a link in the navigation that no longer relates to what is on screen.
For a screen reader user this means the page silently changes and they have no indication of it. For a keyboard user it means tabbing continues from an arbitrary point.
The fix is to move focus to the main content region after navigation:
// components/RouteAnnouncer.tsx
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
export function RouteAnnouncer() {
const pathname = usePathname();
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const main = document.getElementById('main-content');
if (main) {
main.focus();
}
}, [pathname]);
return null;
}
For this to work, the main element must be focusable:
<main id="main-content" tabIndex={-1}>
{children}
</main>
tabIndex={-1} makes an element programmatically focusable without adding it to the tab order. This is exactly what it is for.
Skip the effect on first render — the browser already handles initial page load correctly, and forcing focus there can interfere with anchor links.
Anchor links and dynamic layout
If your page uses scroll-driven animation libraries, anchor navigation can silently fail. The browser attempts to scroll to the target element, but the library is still calculating final layout height. By the time it settles, the scroll position is wrong.
This affects keyboard users disproportionately, because they rely on skip links and in-page navigation more than mouse users do.
The pragmatic fix is to poll for a stable position before scrolling:
function scrollToStableHash(hash: string) {
const target = document.querySelector(hash);
if (!target) return;
let lastTop = -1;
let stableCount = 0;
const check = () => {
const top = target.getBoundingClientRect().top + window.scrollY;
if (Math.abs(top - lastTop) < 1) {
stableCount++;
} else {
stableCount = 0;
lastTop = top;
}
if (stableCount >= 3) {
target.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth',
});
(target as HTMLElement).focus?.();
return;
}
setTimeout(check, 50);
};
check();
}
Use setTimeout rather than requestAnimationFrame here — rAF pauses in background tabs, and users do open links in background tabs.
Modals and focus trapping
A modal that does not trap focus lets keyboard users tab out of it into the page behind, where they cannot see what they are focused on.
Requirements for a correct dialog:
- Focus moves into the dialog when it opens
- Tab cycles within the dialog only
- Escape closes it
- Focus returns to the trigger element on close
- The rest of the page is marked inert or
aria-hidden
Writing this correctly is more work than it looks. Use a tested implementation — Radix UI's Dialog, React Aria, or the native <dialog> element with showModal(), which handles focus trapping and Escape natively.
// Native <dialog> handles most of this for free
const ref = useRef<HTMLDialogElement>(null);
<dialog ref={ref} onClose={handleClose}>
<h2>Title</h2>
{/* content */}
</dialog>
// open with:
ref.current?.showModal();
Browser support for <dialog> is now broad enough to rely on.
The fixes that cost nothing
These take minutes and cover a surprising amount of ground.
Skip link
Lets keyboard users bypass the navigation. Visually hidden until focused:
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2 focus:text-black focus:outline focus:outline-2"
>
Skip to main content
</a>
);
}
Place it as the first focusable element in the layout.
Visible focus indicators
The single worst accessibility decision in web development is outline: none without a replacement. If your design system removes default outlines, add something back:
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
:focus-visible shows the indicator for keyboard focus but not mouse clicks, which is usually what designers actually want when they reach for outline: none.
Semantic landmarks
Screen reader users navigate by landmark. Use real elements:
<header>…</header>
<nav aria-label="Main">…</nav>
<main id="main-content" tabIndex={-1}>…</main>
<footer>…</footer>
Not <div className="header">. If you have multiple <nav> elements, label them so they can be distinguished.
Respecting reduced motion
Some users get nauseated by parallax and large transitions. Respect the preference:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
For JavaScript animations, check the media query directly:
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (!prefersReduced) {
gsap.to(element, { y: 100, duration: 0.6 });
}
Correct language attribute
On a multilingual site, each locale needs its own:
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
Screen readers use this to select pronunciation. A German page marked lang="en" gets read with English phonetics, which is close to unusable.
Why overlay widgets do not work
There is an industry selling a JavaScript snippet that supposedly makes any site accessible: contrast toggles, font size buttons, a read-aloud function.
They do not solve the problem. The widget sits as a layer over a site whose underlying structure is unchanged. A screen reader still reads the original DOM. If headings are missing, form fields unlabelled, or a menu unreachable by keyboard, the overlay changes none of that.
Among actual assistive technology users these tools are strongly disliked — there is a well-known professional pledge against their use, signed by hundreds of accessibility practitioners and disabled users. In the United States, companies have faced lawsuits despite having an overlay installed.
If someone offers you a one-line fix for accessibility, they are selling a false sense of security.
The manual tests that matter
After the automated audit passes, do these. Together they take about fifteen minutes and find more than any tool.
Keyboard only. Put the mouse aside. Tab through the entire page. Can you reach everything interactive? Can you see where you are at all times? Can you complete the main task — submit the form, open the menu, close the modal? Do you ever get stuck?
Zoom to 200 percent. Browser zoom, not device pixel ratio. Does content reflow, or does it require horizontal scrolling? Is anything cut off?
Screen reader, briefly. You do not need to become proficient. VoiceOver on macOS (Cmd+F5) or NVDA on Windows (free). Navigate by headings and landmarks. Does the structure make sense when you cannot see it?
Disable CSS. In DevTools, disable all stylesheets. The page should still be readable in a logical order. If it is not, your HTML structure is wrong regardless of how it looks styled.
The legal angle
If your site serves consumers in the EU, the European Accessibility Act applies from June 2025, transposed into national law in each member state. In Germany it is the BFSG. The technical benchmark is EN 301 549, which references WCAG at level AA.
Micro-enterprises — fewer than 10 employees and under €2M turnover — are exempt for services in the German implementation. Details vary by country, so check your jurisdiction rather than assuming.
For developers the practical takeaway is unchanged: build accessibly by default. It costs almost nothing during development and is expensive to retrofit.
Summary
Lighthouse 100 is a floor, not a ceiling. It means no machine-detectable violations, which is worth having, but it says nothing about whether someone can actually use your site without a mouse.
The React-specific issues — focus on route change, anchor navigation, focus trapping — are the ones automated tools will not tell you about, and they are the ones that make an application genuinely unusable rather than merely awkward.
Start with the free wins: skip link, focus indicators, semantic landmarks, reduced motion. Then put the mouse aside and try to use your own site. That test alone will find things no audit reported.