Accessibility in React: IDs, Focus on Route Change and Announcements
useId for field relationships, focus management in SPAs, an announcement provider, restoring focus after deletion, proven libraries and the jsx-a11y linter

Part 7 of 8 in the "Accessible Web in Practice" series. Previous part: ARIA and Dynamic Content: When Attributes Help and When They Hurt.
React renders regular DOM, so everything from the earlier parts applies directly. But single-page apps have their own traps: unique ids, focus during navigation, announcing changes. Here they are, with ready-to-use code.
Accessibility in React
Syntax details
// In JSX it's htmlFor instead of for, and aria-* attributes are written as is
<label htmlFor="email">Email address</label>
<input id="email" type="email" autoComplete="email" aria-describedby="email-hint" />
Don't add <div> wrappers where they break semantics: a <ul> must contain only <li> elements. To group without extra DOM, use fragments <>...</>.
Unique ids: useId
htmlFor, aria-describedby and aria-labelledby need unique ids. If a component renders several times on a page, a hard-coded id breaks the connection. Since React 18 there's useId:
import { useId } from 'react';
function TextField({ label, hint, error, ...props }) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId} className="hint">{hint}</p>}
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...props}
/>
{error && <p id={errorId} className="error">{error}</p>}
</div>
);
}
// Usage
<TextField label="Email address" type="email" autoComplete="email"
hint="We'll send the confirmation here" error={errors.email} />
Focus on route change
When navigating between pages in an SPA, the browser doesn't reload, and focus stays on the clicked link — or gets lost. A screen reader user never learns that the page changed. A common fix is to move focus to the new page's heading and update document.title:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function PageHeading({ children, title }) {
const ref = useRef(null);
const { pathname } = useLocation();
useEffect(() => {
document.title = `${title} — Agama Labs`;
ref.current?.focus();
}, [pathname, title]);
return (
<h1 ref={ref} tabIndex={-1} className="page-heading">
{children}
</h1>
);
}
/* The heading receives focus programmatically — its outline can be removed */
.page-heading:focus { outline: none; }
The Next.js App Router announces route changes to screen readers by default through a built-in announcer (it reads document.title, falling back to the first h1), which makes a unique title on every page especially important there (Next.js: Accessibility).
An announcement hook
import { createContext, useCallback, useContext, useState } from 'react';
const AnnouncerContext = createContext(() => {});
export function AnnouncerProvider({ children }) {
const [message, setMessage] = useState('');
const announce = useCallback((text) => {
setMessage('');
setTimeout(() => setMessage(text), 100);
}, []);
return (
<AnnouncerContext.Provider value={announce}>
{children}
{/* The region is always rendered; only its text changes */}
<div role="status" aria-live="polite" className="visually-hidden">
{message}
</div>
</AnnouncerContext.Provider>
);
}
export const useAnnounce = () => useContext(AnnouncerContext);
// In a component
function AddToCart({ product }) {
const announce = useAnnounce();
return (
<button type="button" onClick={async () => {
await addToCart(product.id);
announce(`${product.name} added to cart`);
}}>
Add to cart
</button>
);
}
Returning focus after deletion
If a user deletes a list item with a button inside that item, the button disappears along with focus, and focus "falls" to the top of the page. Move it to a neighboring item or to the list heading:
function onDelete(index) {
removeItem(index);
// after render: the next item, else the previous one, else the heading
requestAnimationFrame(() => {
const next = itemRefs.current[index] ?? itemRefs.current[index - 1] ?? listHeadingRef.current;
next?.focus();
});
}
Don't reinvent complex components
A combobox with autocomplete, a date picker, menus, tabs, drag-and-drop — making these accessible from scratch is very hard. There are libraries where that work is done and tested with screen readers:
- React Aria (Adobe) — unstyled hooks and components with the most thorough accessibility work, including mobile screen readers;
- Radix UI — unstyled primitives; shadcn/ui is built on them;
- Headless UI — from the makers of Tailwind CSS.
Linting
eslint-plugin-jsx-a11y catches mistakes as you type: img without alt, onClick on a div with no keyboard handler, invalid ARIA attributes.
npm install --save-dev eslint-plugin-jsx-a11y
// eslint.config.js (flat config)
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
jsxA11y.flatConfigs.recommended,
// ...the rest of your config
];
The "Accessible Web in Practice" series
- Web Accessibility: Who It Really Serves and Where to Start
- Semantic HTML and Alt Text: The Foundation of an Accessible Website
- Accessible Forms: Labels, Hints, Errors and Autofill
- Keyboard and Focus: Building a Website You Can Use Without a Mouse
- Contrast, Motion and Button Size: Visual Accessibility
- ARIA and Dynamic Content: When Attributes Help and When They Hurt
- Accessibility in React: IDs, Focus on Route Change and Announcements — you are here
- How to Test Accessibility: Automation, Manual Checks and a Checklist
Next part: How to Test Accessibility: Automation, Manual Checks and a Checklist
Agama Labs builds websites and web applications. Want to check how accessible your site is? Write to us: hello@agamalabs.com


