Accessibility
Accessibility is not “sprinkle aria”. It is semantics, keyboard flow, focus management, and feedback working together.
Start with semantics
Accessibility is easiest when you do the “boring” basics: correct HTML elements, labels, and relationships. ARIA is a supplement, not the foundation.
- Use <button> for actions and <a href> for navigation. Don’t use div onClick.
- Every input needs a label (visible or aria-label) and a stable name/id.
- If you add ARIA, ensure roles/states are correct and updated together with UI.
Keyboard interaction patterns
A fast way to ship accessible UI is to standardize keyboard behaviors for common patterns.
- Menus/lists: Arrow keys move focus; Enter selects; Esc closes.
- Dialogs: focus moves into dialog on open; returns to trigger on close.
- Tabs: Arrow keys move between tabs; active tab controls panel visibility.
Focus management
- Make focus visible: never remove outlines unless you replace them with an accessible style.
- On route changes, move focus to the new page heading when appropriate.
- On form submit errors, focus the first invalid field and announce a summary.
Make state perceivable (aria-live, role)
Users of assistive technology need feedback when something changes: loading, errors, saved states.
TypeScript
export function SaveStatus({ status }: { status: 'idle' | 'saving' | 'saved' | 'error' }) {
return (
<div aria-live="polite">
{status === 'saving' ? 'Saving…' : null}
{status === 'saved' ? 'Saved.' : null}
{status === 'error' ? <span role="alert">Save failed.</span> : null}
</div>
);
}Example: minimal accessible dialog
A dialog needs: proper role, aria-modal, labeled title, focus on open, and focus return on close.
TypeScript
import { useEffect, useId, useRef } from 'react';
export function Dialog({
open,
title,
onClose,
children,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
triggerRef.current = document.activeElement as HTMLElement | null;
panelRef.current?.focus();
}, [open]);
useEffect(() => {
if (open) return;
triggerRef.current?.focus();
}, [open]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4"
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<div
ref={panelRef}
tabIndex={-1}
className="bg-white rounded p-4 w-full max-w-md dark:bg-gray-800"
>
<div className="flex items-center justify-between gap-3">
<h2 id={titleId} className="font-bold">
{title}
</h2>
<button type="button" onClick={onClose} aria-label="Close">
×
</button>
</div>
<div className="mt-3">{children}</div>
</div>
</div>
);
}Checklist
- All interactive elements are reachable with Tab.
- Focus is visible and returns to a sensible place after closing dialogs.
- Form errors are announced (role=alert) and connected (aria-describedby).
- No keyboard traps: Esc closes dialogs, focus returns to a sensible element.
- Interactive controls have an accessible name (label/aria-label).