React & Next.js Accessibility Patterns
This is the React and Next.js section of Modern Framework Accessibility, sitting alongside Core Accessibility Principles for Modern Frameworks and Testing & Automating Accessibility. It is written for frontend and UI engineers who already ship React or Next.js in production and now need those interfaces to meet WCAG 2.2 without slowing the team down.
Accessibility in a modern React stack is rarely a single missing attribute. It emerges at the seams between server-rendered markup, the hydration boundary, and the client runtime where keyboard, focus, and screen-reader announcements actually execute. This guide maps those seams to concrete, copy-ready patterns and hands each deep topic off to a dedicated guide so you can go as deep as the problem demands.
Targeted WCAG 2.2 Success Criteria:
1.3.1 Info and Relationships(Level A)2.1.1 Keyboard(Level A)2.4.3 Focus Order(Level A)4.1.2 Name, Role, Value(Level A)4.1.3 Status Messages(Level AA)
The diagram above frames the rest of this guide: each rendering layer owns a distinct slice of accessibility responsibility. The sections that follow walk those layers in order, from the WCAG criteria that define the target, down through semantic markup, ARIA, keyboard and focus, live regions, Server Components, and forms.
What you will learn
Each topic below is summarized here and expanded in a dedicated guide. Use this as a map:
- Component libraries and headless primitives — evaluating, extending, and forwarding ARIA through third-party UI. See Accessible Component Libraries in React.
- Reusable accessibility logic — encapsulating focus, announcements, and keyboard handling in hooks. See React Hooks for Accessibility.
- Routing and navigation — route-change announcements, focus restoration, and skip links in the App Router. See Next.js App Router & A11y.
- Server Components and boundaries — placing
'use client', streaming, and progressive enhancement. See Server Components & Client-Side Interactivity. - Dynamic state and announcements — polite versus assertive live regions and announcement queues. See Dynamic Content & State Announcements.
- Forms and validation — error association, error summaries, and React Hook Form. See Form Handling with React Hook Form & A11y.
- Data tables and grids — semantic tables, sortable headers, and virtualization. See Accessible Data Tables & Grids in React.
WCAG 2.2 principles mapped to component development
WCAG success criteria are written for pages, but in a component architecture each criterion resolves to a specific responsibility owned by a specific layer. Mapping them explicitly prevents the common failure where "the page passes axe" but the actual interaction model is broken.
- 1.3.1 Info and Relationships is established on the server. Heading hierarchy, landmark roles (
<main>,<nav>,<header>), and label-to-control associations should exist in the initial HTML, before any JavaScript runs, so structure survives even if hydration is slow or fails. - 2.1.1 Keyboard is a client-runtime obligation. Every interactive element rendered by a component must be operable with the keyboard alone, including custom widgets that replace native controls.
- 2.4.3 Focus Order governs client-side routing. Because SPA navigation suppresses the full page reload that normally resets focus, you must restore focus deliberately after each transition and never inject phantom tab stops.
- 4.1.2 Name, Role, Value is where component composition most often leaks. A control's accessible name, its role, and its current value must be exposed programmatically — and must travel intact across every wrapper and abstraction layer between the leaf DOM node and the consumer.
- 4.1.3 Status Messages is delivered through live regions. Asynchronous outcomes — a saved record, a failed request, a filtered result count — must be announced without moving focus, which means the region has to exist in the DOM before the update fires.
The remaining sections take these five obligations and show the concrete React and Next.js code that satisfies each.
Semantic HTML & the accessibility tree
Before any ARIA, the accessibility tree the browser builds from your markup is what assistive technology actually reads. Native elements populate that tree with a correct role, name, and set of states for free; a <div> populates it with nothing. The single highest-leverage decision in an accessible React codebase is to render the right native element — a <button> for actions, an <a href> for navigation, <ul>/<li> for lists, <table> for tabular data — and to reserve custom markup for cases where no native element fits.
Next.js makes semantic-first rendering easier because React Server Components emit that markup with zero client JavaScript. Establish the document's landmarks and heading order in the server tree, then hydrate only the interactive islands on top:
// app/layout.tsx — semantic landmarks established server-side
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="skip-link">Skip to content</a>
<header>{/* nav landmark lives here */}</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>{/* contentinfo */}</footer>
</body>
</html>
);
}
Two rules keep the accessibility tree honest as components compose. First, when a wrapper must render a different element than its default — a styled Button that should behave as a link — prefer composition (the asChild / render-as-child pattern) over cloning an element and re-attaching handlers, so the correct native semantics survive. Second, never use aria-hidden on a container that still holds focusable children; hiding a subtree from the accessibility tree while leaving it in the tab order produces a control a screen reader cannot describe but a keyboard can still reach. Getting these foundations right is the subject of Core Accessibility Principles for Modern Frameworks, which this pattern library builds on.
ARIA: roles, states, properties — and when NOT to use ARIA
The first rule of ARIA is not to use it when a native element already carries the semantics you need. ARIA changes how assistive technology interprets an element but adds no behavior — a role="button" on a <div> announces "button" yet still does nothing on Enter or Space until you wire up the key handlers, focusability, and disabled state that a real <button> provides for free. Reach for ARIA only to describe custom widgets that have no native equivalent, and when you do, implement the role, its states, and its keyboard model together as one unit.
Forwarding names and descriptions across boundaries
Library integration breaks accessibility most often where an accessible name or description needs to travel across a component boundary. Headless libraries such as Radix, React Aria, and Reach solve this by generating stable ids with useId and wiring aria-labelledby/aria-describedby for you. When you build your own primitives, replicate that contract: accept and forward id, forward refs, and let consumers override aria-label through rest props rather than hard-coding it.
import { forwardRef } from 'react';
interface AccessibleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
isLoading?: boolean;
}
export const AccessibleButton = forwardRef<HTMLButtonElement, AccessibleButtonProps>(
({ variant = 'primary', isLoading, children, disabled, ...rest }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
aria-busy={isLoading}
aria-disabled={disabled || isLoading ? 'true' : undefined}
className={`btn btn-${variant}`}
{...rest} // Safely spreads aria-describedby, aria-label, etc.
>
{isLoading ? <span aria-hidden="true">⟳</span> : children}
</button>
);
}
);
AccessibleButton.displayName = 'AccessibleButton';
A common regression is a wrapper component that swallows aria-describedby because it never spreads ...rest onto the underlying <input> — the visible help text exists, but the screen reader never associates it. Evaluating a dependency before you adopt it, and extending it without breaking this contract, is covered in Accessible Component Libraries in React. Before pulling any UI dependency into the bundle, tab through every interactive surface with the keyboard alone, verify Escape and arrow-key behavior for composite widgets, and confirm each component renders the correct native element — a "button" that is actually a <div> is an immediate disqualifier.
Dialogs: prefer the native element
Before hand-rolling a modal, consider the native <dialog> element with showModal(). It traps focus, renders on the top layer, handles Escape, and exposes an inert backdrop for free — behavior that previously required hundreds of lines of custom ARIA and event wiring. A custom implementation remains valuable for non-modal overlays or design-system constraints, but native first is the correct default. Whichever you use, three invariants hold: focus moves into the dialog on open, focus is restored to the triggering element on close, and content behind the dialog is removed from the accessibility tree with inert or aria-hidden so a screen reader cannot wander out of the modal.
Composite widgets carry a keyboard contract
Menus, tabs, listboxes, comboboxes, and grids each define a specific keyboard contract in the WAI-ARIA Authoring Practices Guide, and partial implementation is often worse than none — a role="tablist" that does not respond to arrow keys actively misleads a screen reader user about how to operate it. The shared pattern is roving tabindex: exactly one descendant carries tabindex="0" at any moment while the rest are tabindex="-1", and arrow keys move the 0 between them. This keeps the entire widget a single Tab stop while exposing internal arrow navigation. Never ship the role without the interaction model it promises.
Testing note: run automated contrast and semantic checks, then verify role, state, and property synchronization manually across NVDA, JAWS, and VoiceOver. Automated tooling catches missing labels and contrast; only manual testing surfaces a broken keyboard-interaction model.
Keyboard navigation & focus management in SPAs
Client-side navigation suppresses the full page reload, which silently strips screen readers of the context a reload normally provides. Focus is left on a link that no longer exists, and nothing announces that the page changed. You have to orchestrate both focus and announcement yourself, and the Next.js App Router & A11y guide covers the App Router specifics in depth.
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
export function RouteFocusManager() {
const pathname = usePathname();
const mainHeadingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// 1. Announce route change to assistive technology
const liveRegion = document.getElementById('route-announcer');
if (liveRegion) {
liveRegion.textContent = `Navigated to ${document.title}`;
}
// 2. Programmatically restore focus to main content heading
// Ensures keyboard users don't lose their place after navigation
mainHeadingRef.current?.focus({ preventScroll: true });
}, [pathname]);
return null; // Logic-only component injected into root layout
}
Choosing the right focus target
Sending focus to the <h1> works for content pages, but it is not always the correct target. For an app shell where navigation occurs inside a persistent layout, focusing the <main> landmark (with tabIndex={-1}) is often clearer because it places the user at the top of the changed region without implying the heading itself is interactive. Whichever target you choose, use { preventScroll: true } so a keyboard user's viewport is not yanked, then let the browser's native scroll-into-view follow the focus ring. Never apply a persistent tabindex="0" to a heading or landmark — that injects a phantom tab stop into the keyboard order, violating a predictable Focus Order (2.4.3).
Trapping focus in overlays
When a native <dialog> will not do, a focus trap keeps Tab and Shift+Tab cycling inside the overlay and restores focus to the trigger on close. This is the kind of logic best encapsulated in a hook — see React Hooks for Accessibility for the broader family:
'use client';
import { useEffect, useRef, useCallback } from 'react';
const FOCUSABLE_SELECTORS = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
const previousFocusRef = useRef<HTMLElement | null>(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS));
if (focusableElements.length === 0) return;
const firstEl = focusableElements[0];
const lastEl = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
(lastEl as HTMLElement).focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
(firstEl as HTMLElement).focus();
}
}
}, [isActive, containerRef]);
useEffect(() => {
if (isActive) {
previousFocusRef.current = document.activeElement as HTMLElement;
document.addEventListener('keydown', trapFocus);
const firstFocusable = containerRef.current?.querySelector(FOCUSABLE_SELECTORS) as HTMLElement;
firstFocusable?.focus();
} else {
document.removeEventListener('keydown', trapFocus);
previousFocusRef.current?.focus();
}
return () => document.removeEventListener('keydown', trapFocus);
}, [isActive, trapFocus, containerRef]);
}
Testing note: verify focus trapping, route-change announcements, and keyboard-only navigation with axe DevTools and VoiceOver. Confirm Escape closes overlays and returns focus, and that the focus outline stays visible against your theme.
Screen-reader behaviour and live regions
Communicating asynchronous state changes to assistive technology requires a live region that already exists in the DOM before the update fires. If you mount the announcer conditionally on first use, the very first update after load is silent because the screen reader had nothing to observe at hydration time. Render a persistent, empty region in the server-rendered layout so assistive technology subscribes to it immediately:
// app/layout.tsx — persistent announcer in server-rendered DOM
<div
id="route-announcer"
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
One application-level announcer
Coupling announcements to component-local live regions scatters duplicate regions across the tree and makes politeness inconsistent. A cleaner architecture exposes a single application-level announcer through context and a useAnnounce hook, so any component can push a message without owning DOM. The hook clears the region before writing the new text; some screen readers will not re-announce identical consecutive strings, and clearing forces the mutation to register. The full announcement-queue design lives in Dynamic Content & State Announcements.
'use client';
import { createContext, useContext, useRef, useCallback } from 'react';
const AnnouncerContext = createContext<(msg: string, assertive?: boolean) => void>(() => {});
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const politeRef = useRef<HTMLDivElement>(null);
const assertiveRef = useRef<HTMLDivElement>(null);
const announce = useCallback((msg: string, assertive = false) => {
const region = assertive ? assertiveRef.current : politeRef.current;
if (!region) return;
region.textContent = ''; // clear so repeats re-announce
requestAnimationFrame(() => { region.textContent = msg; });
}, []);
return (
<AnnouncerContext.Provider value={announce}>
{children}
<div ref={politeRef} role="status" aria-live="polite" aria-atomic="true" className="sr-only" />
<div ref={assertiveRef} role="alert" aria-live="assertive" aria-atomic="true" className="sr-only" />
</AnnouncerContext.Provider>
);
}
export const useAnnounce = () => useContext(AnnouncerContext);
Polite versus assertive, and optimistic UI
Reserve assertive (and role="alert") for content the user must hear immediately — a failed payment, a session-expiry warning, a destructive confirmation. Everything else — "Saved", "3 results", "Item added to cart" — belongs in a polite region so it queues behind whatever the screen reader is currently speaking. With React's useOptimistic, the UI updates instantly while the server request is in flight; pair the optimistic render with a polite announcement on success and an assertive correction on failure, so a screen reader user receives the same provisional-then-confirmed feedback that the optimistic UI gives a sighted user. Also derive route announcements from a controlled route-to-label map rather than a possibly-stale document.title, which can race the navigation effect and announce a verbose or outdated string.
Testing note: validate announcement timing and politeness with NVDA and JAWS. Ensure rapid state changes are debounced so the queue never floods, and confirm live regions survive server-to-client transitions without duplicating.
Server Components, client boundaries and the hydration gap
Managing interactivity across React Server Components and client islands requires deliberate boundary placement. The accessibility heuristic for placing 'use client' is to keep the boundary as low in the tree as possible while still capturing everything that shares interactive state. Push it too high and you turn static semantic markup into a hydrated island, inflating the JavaScript that must download before keyboard handlers attach — the user sees a button but cannot operate it during the hydration gap. Push it too low and you fragment one widget across islands, which can desynchronize ARIA state between a trigger and the region it controls. Keep the trigger and its controlled region inside the same client component so aria-expanded and aria-controls always reflect a single source of truth. Server Components & Client-Side Interactivity works through boundary placement in detail.
import { Suspense } from 'react';
import { ClientInteractiveWidget } from './client-widget';
export default function DashboardPage() {
return (
<main>
<h1>Analytics Dashboard</h1>
<Suspense fallback={<AccessibleSkeleton aria-busy="true" aria-label="Loading analytics data" />}>
<ClientInteractiveWidget />
</Suspense>
</main>
);
}
function AccessibleSkeleton({ 'aria-busy': busy, 'aria-label': label }: { 'aria-busy': string; 'aria-label': string }) {
return (
<section role="region" aria-busy={busy} aria-label={label}>
<div className="skeleton-block" aria-hidden="true" />
<div className="skeleton-block" aria-hidden="true" />
<p className="sr-only">Loading content. Please wait.</p>
</section>
);
}
Streaming, Suspense, and announcement order
Streaming SSR flushes HTML in chunks as server data resolves. For sighted users this is progressive paint; for screen-reader users it can mean content is announced out of visual order, or a live region fires before the region it references has streamed in. Two rules keep this predictable: render live regions and any element targeted by aria-controls/aria-describedby in the initial, non-suspended shell so the reference target always exists; and give each Suspense fallback an honest busy state so the user is told work is in progress rather than encountering apparent silence. Avoid moving focus into content that is still suspended — the focus target may not exist yet, and the call will silently no-op.
The hydration gap is an accessibility gap
Time-to-Interactive is not merely a performance metric; it is the window during which a control is visually present but not yet operable. A keyboard user who tabs to a button before its handler hydrates gets no response. Minimizing this gap — by keeping client islands small, deferring non-critical scripts, and rendering as much as possible on the server — is therefore a direct accessibility improvement, and the two goals align: less client JavaScript means faster interactivity and fewer hydration mismatches that desynchronize the accessibility tree. When you defer a component with dynamic(() => import('./X'), { ssr: false }), always supply a loading fallback with an honest busy state, and never defer primary content or navigation landmarks — a screen-reader user on a slow connection would otherwise reach a page whose main region is simply absent. Respect prefers-reduced-motion in any skeleton or transition you add.
Server Actions and the no-JS baseline
A genuine accessibility strength of the App Router is that a form wired to a Server Action submits and revalidates without client JavaScript. Treat that as the baseline: the form must be fully operable with semantic <form>, <label>, and native validation before you layer on useFormStatus for a busy indicator or useActionState for inline error rendering. When you enhance it, surface the action's pending state through aria-busy on the submit control and announce returned errors through a live region, so the enhanced path is at least as accessible as the baseline it replaces.
Accessible forms, errors and validation
Forms are where validation logic and ARIA attributes must be mapped explicitly, or keyboard and screen-reader flow break. Every error needs to be programmatically associated with its field through aria-invalid and aria-describedby, and every control needs a real <label> — placeholder text is not an accessible name and disappears the moment the user types. Form Handling with React Hook Form & A11y covers uncontrolled rendering and the full validation lifecycle.
'use client';
import { useForm } from 'react-hook-form';
import { useState, useRef } from 'react';
export default function AccessibleContactForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const [hasError, setHasError] = useState(false);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const onSubmit = () => setHasError(false);
const onError = () => {
setHasError(true);
// Move focus to error summary for immediate AT announcement
setTimeout(() => errorSummaryRef.current?.focus(), 0);
};
return (
<form onSubmit={handleSubmit(onSubmit, onError)} noValidate>
{hasError && (
<div
ref={errorSummaryRef}
role="alert"
aria-live="assertive"
tabIndex={-1}
id="form-error-summary"
className="error-summary"
>
<p>Please correct the highlighted errors below.</p>
</div>
)}
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register('email', { required: 'Email address is required' })}
/>
{errors.email && (
<span id="email-error" className="error-text" role="alert">
{errors.email.message as string}
</span>
)}
<button type="submit">Submit</button>
</form>
);
}
An error summary that links to fields
A flat "please fix the errors" banner satisfies the announcement requirement but not the navigation one. The GOV.UK pattern — widely cited as the accessibility benchmark for forms — renders the summary as a list of in-page anchors, one per invalid field, each pointing at the field's id. Activating a link moves focus straight to the offending input. Build the summary from React Hook Form's errors object, render it in source order so the listed order matches the visual field order, and move focus to the summary container on submit failure.
{hasError && (
<div ref={errorSummaryRef} role="alert" tabIndex={-1} className="error-summary">
<h2>There is a problem</h2>
<ul>
{Object.entries(errors).map(([name, error]) => (
<li key={name}>
<a href={`#${name}`}>{error?.message as string}</a>
</li>
))}
</ul>
</div>
)}
Validate without interrupting
Validating on every keystroke is hostile to screen-reader users: each change re-announces the field and can interrupt typing feedback. Prefer validation on blur (or on submit), so the error is associated and announced once the user has finished with the field. Keep the inline error referenced through aria-describedby rather than only role="alert" on a separate node — aria-describedby guarantees the error is read whenever the field regains focus, not just at the instant it appears. Ensure noValidate is present so native browser validation does not conflict with your custom ARIA mappings.
Testing note: test submission flows with keyboard only, verify the announced error order matches the visual layout, and confirm activating a summary link lands focus on the correct field.
Accessible data tables & grids
Tabular data is where teams most often abandon native semantics, and it shows in the screen-reader experience. A table built from <div>s loses row and column relationships entirely; a user cannot ask "what column am I in?" and gets no header context when navigating cells. Start from native <table>, <thead>, <tbody>, <th scope="col">, and <th scope="row">, and add a <caption> that names the table. Reach for role="grid" only when the component genuinely needs spreadsheet-style keyboard interaction — cell-by-cell arrow navigation, editable cells, or in-cell widgets. A read-only table with sortable headers needs native semantics plus aria-sort, nothing more:
function SortableHeader({ label, sortKey, current, direction, onSort }: {
label: string; sortKey: string; current: string;
direction: 'ascending' | 'descending'; onSort: (k: string) => void;
}) {
const isSorted = current === sortKey;
return (
<th scope="col" aria-sort={isSorted ? direction : 'none'}>
<button type="button" onClick={() => onSort(sortKey)}>
{label}
<span aria-hidden="true">{isSorted ? (direction === 'ascending' ? ' ▲' : ' ▼') : ''}</span>
</button>
</th>
);
}
For large datasets, virtualization (rendering only visible rows) actively breaks table semantics: a screen reader announces "row 12 of 12" when only twelve of ten thousand rows are in the DOM. Set aria-rowcount on the table and aria-rowindex on each rendered row to communicate the true totals, and prefer pagination over infinite virtual scroll when the data model allows — pagination gives assistive technology a stable, finite document to navigate. The deeper patterns, including editable grids and selection models, are covered in Accessible Data Tables & Grids in React.
Key takeaways
- Establish semantic HTML, landmarks, and heading order in the server tree so structure survives before and even without hydration.
- Render the correct native element first; use ARIA only for custom widgets, and always ship the role together with its keyboard model.
- After every client-side route change, restore focus deliberately with
{ preventScroll: true }and never leave a persistenttabindex="0"on a heading or landmark. - Render live regions in the initial DOM; route non-urgent updates to
politeand reserveassertive/role="alert"for what the user must hear immediately. - Keep the trigger and the region it controls inside the same
'use client'boundary soaria-expandedandaria-controlsstay in sync, and keep islands small to shrink the hydration gap. - Associate every form error through
aria-invalidandaria-describedby, provide a focusable error summary that links to fields, and validate on blur or submit rather than on every keystroke. - Never defer primary content or navigation with
ssr: false, and keep native<table>semantics — witharia-rowcount/aria-rowindex— even when you virtualize.
Frequently Asked Questions
How do I prevent screen reader confusion during Next.js client-side navigation?
Implement route-change announcements using a centralized aria-live region, restore focus to the main content heading or landmark after navigation, and keep landmark roles consistent across layouts. Render the live region in the server-side DOM so it exists before the first navigation, and derive announcements from a controlled route-to-label map rather than a possibly-stale document.title.
When should I use React Server Components versus Client Components for accessibility?
Use Server Components for static, semantic content to reduce JS payload and improve initial render. Use Client Components only for interactive elements requiring state, event listeners, or browser APIs, and progressively enhance them with accessible fallbacks. Keep the 'use client' boundary as low as possible, but keep a trigger and the region it controls inside the same client component so aria-expanded and aria-controls stay in sync.
How do I handle dynamic content updates without flooding the screen reader?
Use aria-live="polite" for non-urgent updates and assertive only for critical alerts. Debounce or batch rapid DOM changes, and ensure live regions are present in the DOM before content updates occur to prevent silent failures. A single application-level announcer behind a useAnnounce hook avoids duplicate regions and inconsistent politeness.
Is it better to build custom accessible components or use a library?
For most teams, a well-maintained accessible library reduces ARIA debt and testing overhead. Build custom components only when design requirements exceed library capabilities, and test them rigorously against the WAI-ARIA Authoring Practices. When you extend a library, forward id and refs and spread rest props so accessible names and descriptions travel across component boundaries intact.
Should I use the native <dialog> element or build a custom modal?
Prefer native <dialog> with showModal(): it traps focus, handles Escape, renders on the top layer, and supplies an inert backdrop without custom code. Build a custom modal only when design-system constraints or non-modal overlay behavior require it, and in that case still guarantee focus moves in on open, returns to the trigger on close, and background content is inert.
How do I keep an accessible data table performant when it has thousands of rows?
Keep native <table> semantics with <th scope> and a <caption>, then use pagination rather than infinite virtual scroll where the data model allows. If you must virtualize, set aria-rowcount on the table and aria-rowindex on each rendered row so screen readers report the true totals instead of only the rows currently in the DOM.
Related guides
- Modern Framework Accessibility — the parent index for every framework covered on this site.
- Core Accessibility Principles for Modern Frameworks
- Testing & Automating Accessibility
- Next.js App Router & A11y
- Server Components & Client-Side Interactivity
- React Hooks for Accessibility
- Accessible Component Libraries in React
- Dynamic Content & State Announcements
- Form Handling with React Hook Form & A11y
- Accessible Data Tables & Grids in React
- Accessible Search & Filtering in React