Accessibility bugs rarely come from exotic cases. They come from a div with an onClick, a modal that leaves focus behind the overlay, or a toast that a screen reader never announces. Each one is easy to write and invisible until you try the page without a mouse.
I check five things on every component: where focus goes, whether the keyboard can do everything the mouse can, whether changes are announced, whether the dialog behaves, and whether motion respects the user. Here are the patterns, in that order.
Start with the right element
Before any ARIA, use the element that already does the job. A button is focusable, responds to Enter and Space, and has the right role. A div has none of that, and you would have to rebuild all of it.
<div className="btn" onClick={save}>
Save
</div><button type="button" onClick={save}>
Save
</button>Focus management
Focus is where the keyboard and screen reader are “standing”. When your UI changes and focus does not follow, the user is left on something that no longer matters, or on nothing at all.
Three moments need care: something opens, something closes, and something is removed while it has focus.
A common case is moving focus to a heading after a route-like change, so a screen reader announces the new content. The element needs tabIndex={-1} to be focusable by code without joining the tab order.
import { useEffect, useRef } from "react";
export function StepHeading({ step, children }: { step: number; children: React.ReactNode }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
ref.current?.focus();
}, [step]);
return (
<h2 ref={ref} tabIndex={-1}>
{children}
</h2>
);
}This is one of the real uses of an effect, because focus belongs to the browser, not to React. I wrote about when that is true in the useEffect post.
Modals: use the platform
A correct modal needs focus moved in, focus kept inside, background content made unreachable, Escape to close, and focus returned to the trigger. Writing all that by hand is where most bugs come from.
The native dialog element does most of it. Calling showModal() puts it in the top layer, makes the rest of the page inert, moves focus into it, and closes it on Escape.
import { useEffect, useRef } from "react";
type ModalProps = {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
};
export function Modal({ open, onClose, title, children }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby="modal-title"
onClose={onClose}
>
<h2 id="modal-title">{title}</h2>
{children}
<button type="button" onClick={onClose}>Close</button>
</dialog>
);
}The close event fires for Escape as well as for dialog.close(), so listening to onClose keeps your React state in sync. Browsers also return focus to the element that opened the dialog when it closes, in current versions, but I still test that, as it is the first thing to break when a library wraps it.
If you cannot use dialog, a well-tested library such as Radix UI or React Aria gives you the same guarantees. I would not hand-roll a focus trap for production.
Popovers and menus
A popover is lighter than a modal. It does not take over the page, so focus is not trapped, but it still needs a way in and a way out.
My checklist for a trigger plus popup:
- The trigger is a real
buttonwitharia-expandedandaria-controls. - Escape closes it and returns focus to the trigger.
- Clicking outside closes it.
- Tab moves on naturally, and does not get stuck inside.
import { useEffect, useId, useRef, useState } from "react";
export function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div>
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
{label}
</button>
{open && <div id={panelId}>{children}</div>}
</div>
);
}Note the cleanup function. Without it you leak a listener every time the panel opens. Also note that this is a disclosure, not an ARIA menu. A real role="menu" promises arrow key navigation, and promising it without building it is worse than using plain buttons.
Keyboard support
Everything a mouse can do must work from the keyboard, in an order that makes sense. For custom widgets such as tabs, toolbars or listboxes, the usual pattern is a roving tabindex: one item is in the tab order, and the arrow keys move between items.
import { useRef, useState } from "react";
export function Tabs({ tabs }: { tabs: string[] }) {
const [active, setActive] = useState(0);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const move = (next: number) => {
const index = (next + tabs.length) % tabs.length;
setActive(index);
refs.current[index]?.focus();
};
const onKeyDown = (e: React.KeyboardEvent, i: number) => {
if (e.key === "ArrowRight") move(i + 1);
if (e.key === "ArrowLeft") move(i - 1);
if (e.key === "Home") move(0);
if (e.key === "End") move(tabs.length - 1);
};
return (
<div role="tablist">
{tabs.map((name, i) => (
<button
key={name}
ref={(el) => { refs.current[i] = el; }}
role="tab"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
onClick={() => setActive(i)}
>
{name}
</button>
))}
</div>
);
}This is only the tab strip. A complete tabs widget also needs role="tabpanel" panels linked with aria-controls and aria-labelledby. The WAI-ARIA Authoring Practices describe the full key map for each widget, and I check it before inventing my own.
Also keep the focus ring. outline: none with no replacement removes the only sign of where the keyboard is. Style it with :focus-visible if you do not want it on mouse clicks.
Announcing changes with aria-live
Sighted users see a toast or an updated count. Screen reader users hear nothing unless you tell the browser to announce it. A live region does that.
The key detail: the region must already be in the DOM before its content changes. If you mount the region and its text together, many screen readers stay silent.
export function SearchStatus({ count }: { count: number | null }) {
return (
<p role="status" aria-live="polite">
{count === null ? "" : `${count} results found`}
</p>
);
}{message && <p role="status">{message}</p>}In the broken version the region appears at the same time as its text, so the announcement is often lost. Render the container always and change only the text inside.
Use polite for most things, because it waits for the user to finish. role="alert" (assertive) interrupts, so keep it for errors that block the user. Do not put live regions on things that update every second, or the page becomes unusable with a screen reader.
Reduced motion
Some people get dizzy or sick from large movement, parallax or autoplaying animation. The operating system has a setting for it, and the browser exposes it as prefers-reduced-motion.
In CSS, the simplest approach is to remove the movement and keep the state change.
.panel {
transition: transform 300ms ease, opacity 300ms ease;
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition: opacity 150ms ease;
transform: none;
}
}For animation driven by JavaScript, read the same query with matchMedia, and listen for changes, since users can toggle the setting while the page is open.
import { useSyncExternalStore } from "react";
const query = "(prefers-reduced-motion: reduce)";
function subscribe(callback: () => void) {
const mql = window.matchMedia(query);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
export function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false, // server snapshot
);
}Reduced does not mean none. A fade is usually fine. The things to remove are large movement, zoom, spinning and parallax.
- Native element?button, dialog, label
- Keyboard onlyTab, Shift+Tab, Enter, Space, Escape
- Focus in, out and backopen, close, remove
- Screen readername, role, state, live updates
- Reduced motion onOS setting, then reload
Five passes, in this order. Most bugs show up in the first two.
What to test
Automated tools catch only part of the problem. I use three layers.
- Unit tests with Testing Library. Query by role and name, the way a user would. If
getByRole("button", { name: "Save" })fails, a screen reader would struggle too. Assert on focus withtoHaveFocus()afteruserEvent.keyboard("{Escape}"). - An automated audit.
jest-axein unit tests, or@axe-core/playwrightin end-to-end tests, finds missing names, bad ARIA and contrast problems. It cannot tell you that focus went to the wrong place. - A manual pass. Unplug the mouse, tab through the page, then try one screen reader. VoiceOver on a Mac, NVDA on Windows. Ten minutes finds what the tools miss.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("returns focus to the trigger after Escape", async () => {
const user = userEvent.setup();
render(<Example />);
const trigger = screen.getByRole("button", { name: "Open settings" });
await user.click(trigger);
expect(screen.getByRole("dialog")).toBeVisible();
await user.keyboard("{Escape}");
expect(trigger).toHaveFocus();
});One caveat: jsdom does not fully implement HTMLDialogElement, so you may need to polyfill showModal in unit tests, or cover the modal in a real browser test instead.
Common mistakes
- Clickable
divandspanelements with no role, no tabindex and no key handler. - Removing the focus outline without adding a replacement.
- A modal that opens but never moves focus inside, or never gives it back.
- Using
tabIndexgreater than zero, which creates a tab order nobody can predict. - Icon-only buttons with no accessible name. Add
aria-labelor visually hidden text. - A live region created at the same moment as its message.
- Using
aria-hidden="true"on something that contains a focusable element. - Animation that ignores
prefers-reduced-motion. - Placeholder text used as the only label for an input.
- Adding ARIA roles without the keyboard behavior those roles promise.
Interview answer in 30 seconds
“I start with native elements, because they give me role, focus and keyboard behavior for free. For modals I use dialog with showModal, or a tested library, so focus is trapped and returned to the trigger. Custom widgets get a roving tabindex and the key map from the ARIA Authoring Practices. Dynamic messages go in a live region that already exists in the DOM. I respect prefers-reduced-motion. To verify, I query by role in tests, run axe, and do a keyboard and screen reader pass by hand.”
Key takeaways
- Use the native element first. ARIA does not add behavior.
- Move focus on open, return it on close, and never leave it on a removed element.
- Prefer
dialogwithshowModalor a tested library over a hand-rolled focus trap. - Anything a mouse can do must work with the keyboard, and the focus ring must stay visible.
- Live regions must exist before their content changes. Use
politeby default. - Honor
prefers-reduced-motion: remove large movement, keep simple fades. - Automated checks find only part of the problems. Always do a manual keyboard pass.
