Accessible Components
Accessibility failures in component code are not distributed randomly. Almost all come from four places: rebuilding something the browser already provides, mishandling focus, naming things wrongly, and failing to announce change. Everything below is organised around those.
The mental model that prevents most of it: ARIA changes what a component is announced as,
and changes nothing about what it does. Adding role="button" to a div does not make it
focusable, does not make Enter or Space activate it, and does not give it a disabled state.
You have promised the assistive technology a button and delivered a rectangle.
1. Use the native element
<button> gives you, free: tab-order membership, Enter and Space activation, the button
role, a name from its own text content, a user-agent :focus-visible indicator, disabled
semantics that both remove it from the tab order and announce it as unavailable, form
submission via type="submit", forced-colors rendering, and touch-and-explore activation
on mobile screen readers, which does not fire a click on arbitrary divs.
Rebuilding that on a div takes role="button", tabindex="0", a keydown handler for both
Enter and Space with preventDefault on Space to stop page scroll, aria-disabled plus
your own logic to actually block activation, and a hand-written focus ring. Six things to
get right and keep right, in exchange for nothing. The same holds for <a href>,
<input type="checkbox">, <details>, and <dialog> with showModal(), which supplies
top-layer stacking, background inertness, Escape via the cancel event, and focus
restoration to the previously focused element, all specified in HTML.
Reach for ARIA only when the platform has no equivalent — menu buttons, tabs, custom comboboxes, tree views. Then implement the full pattern, not a fragment of it.
2. Focus management is the hard part
Every composite widget needs a decision about where focus lives. There are two correct mechanisms.
Roving tabindex: exactly one item in the group carries tabindex="0", every sibling
carries tabindex="-1", and arrow keys move both DOM focus and the 0. Use it whenever
the focused item is a real element you can focus — tabs, toolbars, menus, trees, radio
groups. It is the more robust option because the browser handles the focus event, the
scrolling, and the ring itself.
aria-activedescendant: DOM focus stays on a container or text input, whose
aria-activedescendant points at the id of the virtually focused option. Use it when focus
must stay in a text field while a list is navigated — comboboxes, and little else. Because
nothing actually moved, the browser will neither scroll the active option into view nor
draw a ring on it. A listbox that does not call scrollIntoView({ block: 'nearest' }) is
one whose selection silently walks off the bottom of the visible area.
Modal dialogs additionally require a trap: Tab from the last tabbable element wraps to
the first, Shift+Tab from the first wraps to the last, and outside content is neither
focusable nor reachable by the screen-reader virtual cursor. Use <dialog>.showModal(), or
inert on the sibling content (Baseline since 2023). Do not use aria-hidden on the
background while leaving it focusable — that is the worst state available, where a keyboard
user tabs into elements the screen reader refuses to describe.
On close, return focus to the element that opened the dialog. If it no longer exists —
deleted row, dismissed card — focus its nearest surviving container with tabindex="-1".
Focus falling to <body> sends a screen-reader user back to the top of the document with
no explanation.
Initial focus goes to the first meaningful control, or — for dialogs with substantial
reading content — to the heading with tabindex="-1", so the title is announced first.
Never autofocus a destructive action.
3. Names, and the aria-label trap
Accessible names are computed in a fixed precedence: aria-labelledby wins, then
aria-label, then the host-language mechanism (<label>, alt, <legend>), then the
element's own text content for roles permitting name-from-content, then title as a last
resort. Each level silently suppresses everything below it, which is what makes
aria-label dangerous: it overrides visible text.
A button reading "Save" with aria-label="Save changes to your profile" is named something
no user can see. Speech-input users say what they see, so "click Save" now matches nothing.
WCAG 2.2 SC 2.5.3 (Label in Name, Level A) requires the accessible name to contain the
visible label text; if you must extend a name, start it with the visible string.
Two more naming failures: aria-label is ignored on elements with the generic role (a
plain <div> or <span>), so labelling a wrapper does nothing; and aria-labelledby
pointing at a missing id fails silently, leaving the component unnamed — invisible in code
review, immediate in a screen reader.
4. Live regions announce nothing if you create them late
A live region must exist in the accessibility tree before its contents change. Screen
readers register the region on insertion and then report mutations to it, so inserting the
container and its text in the same tick usually announces nothing. Render an empty
<div role="status"> on mount, then write text into it.
aria-live="polite" (equivalently role="status") queues behind current speech and is
correct for almost everything: save confirmations, result counts, loading completion.
aria-live="assertive" (role="alert") interrupts mid-word and is only for information
the user must act on immediately.
Other reasons regions go silent: the region is display: none (use a clip-based
visually-hidden class); the same string is written twice, which most screen readers suppress
as unchanged; or the whole subtree is replaced rather than its text mutated.
For validation errors, wire aria-describedby from the field to its message and move focus
to the first invalid field — more reliable than a summary the user cannot navigate to.
5. The WCAG 2.2 criteria component libraries actually fail
- 2.4.7 Focus Visible (AA) —
outline: nonewith no replacement. Removing the default ring obliges you to supply one on:focus-visible. - 1.4.11 Non-text Contrast (AA) — the focus ring, and boundaries such as input borders and unchecked checkboxes, need 3:1 against adjacent colours. A subtle grey border on white is a failure.
- 2.4.11 Focus Not Obscured (Minimum) (AA, new in 2.2) — sticky headers and cookie bars
covering the focused element. Add
scroll-padding-topequal to the sticky header height. - 2.5.8 Target Size (Minimum) (AA, new in 2.2) — pointer targets must be at least 24 by 24 CSS pixels, or spaced so 24px circles centred on them do not intersect. Icon-only buttons and table row actions are the usual offenders.
- 1.4.13 Content on Hover or Focus (AA) — tooltips and hover cards must be dismissible with Escape without moving the pointer, hoverable (the pointer can travel into the bubble without it vanishing), and persistent until dismissed. A tooltip that disappears on mouseout of the trigger fails.
- 3.2.6 Consistent Help (A, new in 2.2) — help affordances appear in the same relative order on every page that has them.
6. Test with a real screen reader
Automated engines check what is statically expressible in the DOM: missing alt text, contrast of solid colours, invalid ARIA values, duplicate ids. They structurally cannot evaluate whether focus went somewhere sensible, whether an announcement was comprehensible, or whether the keyboard model matches the role advertised — which is where most real defects live.
The minimum manual pass: unplug the mouse and operate the component end to end, then run it with VoiceOver and Safari, or NVDA and Firefox. Nearly every serious defect surfaces in the first two minutes.
Rules
MUST NOT — Do not attach click or key handlers to a div, span, or other non-interactive element in order to build a control.
Why: A generic element is not in the tab sequence, has no role, does not respond to Enter or Space, and is not activated by the double-tap gesture mobile screen readers use, so the control is unreachable by keyboard and by touch exploration regardless of how it looks.
Exceptions:
- Container-level handlers that delegate to genuinely interactive descendants, where the descendant is the control.
MUST NOT — Never apply aria-hidden="true" to an element that is focusable or that contains focusable content.
Why: aria-hidden removes an element from the accessibility tree but not from the tab sequence, producing an element a keyboard user can focus and a screen reader refuses to describe — silence with no way to recover context. Use inert, or remove the element, instead.
Source: WAI-ARIA 1.2, aria-hidden
MUST NOT — Do not rely on aria-label or aria-labelledby applied to a plain div or span with no role.
Why: Naming is prohibited on elements mapped to the generic role, so browsers discard the attribute entirely. The markup looks labelled in review and is unnamed at runtime, which is why this failure survives code review reliably.
Source: ARIA in HTML, naming prohibited roles
MUST — Use the native HTML element for any behaviour the platform already provides, and reach for ARIA only when no native equivalent exists.
Why: Native interactive elements ship focusability, key handling, role, name computation, disabled semantics, forced-colors rendering, and mobile touch-exploration activation as one package that stays correct without maintenance. ARIA supplies only the announced role, so every other behaviour must be reimplemented and kept correct forever.
Source: WAI-ARIA Authoring Practices, first rule of ARIA use
Incorrect:
<div className="btn"
Correct:
<button type="button" className="btn"
MUST — When implementing an ARIA pattern, implement its complete keyboard contract, not a subset.
Why: The announced role sets the user expectation. A user told they are in a menu will press Down Arrow, Escape, and Home; if only Enter is wired, the component is less usable than an unstyled list, because it has promised a model it does not honour.
MUST — Escape must dismiss any transient overlay — dialog, menu, popover, tooltip, combobox popup — and return focus to the element that opened it.
Why: Escape is the only universally learned exit gesture, and for keyboard-only users an overlay with no keyboard dismissal is a trap. Returning focus to the trigger preserves the user position in the document, which is otherwise unrecoverable without spatial cues.
Source: WCAG 2.2 SC 2.1.2 No Keyboard Trap
MUST — Trap Tab and Shift+Tab inside a modal dialog and make the background inert while it is open.
Why: A modal asserts that nothing behind it is available. If focus escapes, the user operates controls they cannot see, with no indication of where they are, and the visible overlay makes the focused element impossible to locate.
Incorrect:
<div aria-hidden="true" id="app">…</div>
<div role="dialog">…</div>
Correct:
<div inert id="app">…</div>
<dialog aria-modal="true" aria-labelledby="t">…</dialog>
MUST — Restore focus to the invoking element when an overlay closes, falling back to a surviving ancestor if that element no longer exists.
Why: When focus is not restored it defaults to document.body, which resets a screen reader virtual cursor to the top of the page and resets a keyboard user tab position to the first link. Neither user is told this happened, so the loss of place is silent.
MUST — A composite widget must be a single tab stop, with exactly one descendant at tabindex="0" and all others at tabindex="-1".
Why: Tab moves between widgets and arrow keys move within them. If every item is tabbable, a fifty-item tree costs fifty Tab presses to pass, and the arrow-key model the role advertises becomes redundant.
Exceptions:
- Lists of links or ordinary form fields, which are not composite widgets and correctly place each element in the tab sequence.
Incorrect:
tabs.map((t) => <button role="tab" key={t.id}>{t.label}</button>)
Correct:
tabs.map((t) => <button role="tab" key={t.id} tabIndex={t.id === activeId ? 0 : -1}>{t.label}</button>)
MUST — When using aria-activedescendant, scroll the active option into view and style it explicitly, and keep the referenced id valid on every list change.
Why: aria-activedescendant moves virtual focus only. No focus event fires, so the browser neither scrolls the option into view nor matches :focus against it, and a stale reference to a filtered-out option leaves the widget with no discoverable active item.
Incorrect:
input.setAttribute('aria-activedescendant', option.id)
Correct:
input.setAttribute('aria-activedescendant', option.id)
option.scrollIntoView({ block: 'nearest' })
MUST — Every focusable element must show a focus indicator with at least 3:1 contrast against both the component and the adjacent background.
Why: The focus indicator is the only signal a sighted keyboard user has for their position in the document. Removing the user-agent outline without replacing it makes the interface unusable without a mouse; an indicator below 3:1 is present in the DOM but not perceivable.
Source: WCAG 2.2 SC 2.4.7 Focus Visible (AA) and SC 1.4.11 Non-text Contrast (AA)
Incorrect:
.btn:focus { outline: none; }
Correct:
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
MUST — Ensure a focused element is never entirely hidden behind sticky headers, footers, or floating panels.
Why: Scrolling by focus does not account for author-created overlays, so a sticky header can cover the element the browser just scrolled to. Setting scroll-padding on the scroll container equal to the overlay height makes the browser reserve that space.
Source: WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum), Level AA
Incorrect:
header { position: sticky; top: 0; height: 64px; }
Correct:
header { position: sticky; top: 0; height: 64px; }
html { scroll-padding-top: 64px; }
MUST — Content revealed on hover or focus must be dismissible with Escape, hoverable without disappearing, and persistent until dismissed or invalidated.
Why: Screen magnifier users must move the pointer into the revealed content to read text that extends beyond their viewport; content that hides on mouseout of the trigger is unreadable to them, and content that cannot be dismissed can permanently obscure what is beneath it.
Source: WCAG 2.2 SC 1.4.13 Content on Hover or Focus, Level AA
Incorrect:
<span
Correct:
<button aria-describedby="tip"
MUST — The accessible name of a control must contain its visible label text, in the same order.
Why: Speech-input users issue commands by speaking the label they can see. When aria-label replaces rather than extends the visible text, the spoken command matches nothing, and the control becomes operable only by pointer.
Source: WCAG 2.2 SC 2.5.3 Label in Name, Level A
Incorrect:
<button aria-label="Submit your application">Send</button>
Correct:
<button aria-label="Send application">Send</button>
MUST — Render a live region container into the DOM before the content it will announce changes, rather than inserting the region and its message together.
Why: Assistive technology registers live regions when they enter the accessibility tree and then reports subsequent mutations. A region inserted in the same update as its text has no prior state to be compared against, so in most screen readers nothing is announced at all.
Source: WAI-ARIA 1.2, live region attributes
Incorrect:
{message && <div role="status">{message}</div>}
Correct:
<div role="status" className="sr-only">{message}</div>
MUST — Associate validation messages with their field using aria-describedby, and set aria-invalid on the field when it is in error.
Why: A screen reader reads a field name, role, value, and description on focus. An error rendered as a nearby paragraph is not part of that computation, so a user tabbing to the field hears no indication that anything is wrong.
Source: WCAG 2.2 SC 3.3.1 Error Identification, Level A
Incorrect:
<input id="email" />
<p className="error">Enter a valid email</p>
Correct:
<input id="email" aria-invalid="true" aria-describedby="email-err" />
<p id="email-err" className="error">Enter a valid email</p>
MUST — Operate every new interactive component with the keyboard alone and with at least one screen reader before declaring it complete.
Why: Automated rule engines evaluate statically expressible properties of the DOM. They cannot judge whether focus landed somewhere sensible, whether an announcement was comprehensible, or whether a keyboard model matches the role advertised — which is where the majority of component defects live.
SHOULD NOT — Do not use aria-live="assertive" or role="alert" for routine confirmations, toasts, or progress updates.
Why: Assertive regions interrupt speech in progress, discarding whatever the user was in the middle of hearing. Used for non-urgent updates, they make the interface unusable by repeatedly cutting off the content the user is trying to read.
Exceptions:
- Information requiring immediate action, such as a session about to expire or a failed submission that discards user input.
SHOULD — Give every pointer target a hit area of at least 24 by 24 CSS pixels, or space undersized targets so that 24px circles centred on them do not intersect.
Why: Pointer accuracy varies with motor control, tremor, and input device. Below roughly 24 CSS pixels, mis-taps rise sharply and adjacent-target activation becomes likely, which is why the criterion permits spacing as an alternative to size.
Source: WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA
Exceptions:
- Targets inline within a sentence, whose size is constrained by the line-height of surrounding text.
- Targets whose size is determined by the user agent and not modified by the author.
- An equivalent control meeting the size requirement exists elsewhere on the same page.
SHOULD — Use a native select element for simple option lists, and build a custom listbox or combobox only when filtering, multi-column options, or rich option content is genuinely required.
Why: A native select delegates rendering to the operating system, which supplies platform-correct touch, keyboard, typeahead, and screen-reader behaviour on every device, including mobile pickers that no custom implementation reproduces.
Exceptions:
- Options requiring search, grouping with descriptions, images, or multi-select with tokens.
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Confirm the component satisfies its keyboard contract. (blocking)
- Which ARIA pattern does this component claim to be, and have you implemented every required key in that pattern?
- Can every action performed with the mouse also be performed with the keyboard alone?
- Is the widget a single tab stop with arrow-key navigation inside it, or does Tab step through every item?
- Does Escape dismiss every transient layer this component can open?
Confirm focus is owned, trapped, and restored correctly. (blocking)
- Where does focus go when this component opens, and why is that the right place?
- Can Tab or Shift+Tab escape an open modal to content behind it?
- Where does focus go when it closes, and what happens if that element was removed while it was open?
- After every interaction, would document.activeElement ever be body?
- If aria-activedescendant is used, is the active option scrolled into view and visibly styled?
Confirm every control is correctly named. (blocking)
- Does every interactive element have a non-empty accessible name?
- For each aria-label, does the name begin with the visible label text?
- Does every aria-labelledby and aria-describedby reference an id that exists in the rendered DOM?
- Is any aria-label applied to a div or span that has no role?
- Do icon-only buttons have names describing the action rather than the icon?
Confirm dynamic changes are announced.
- Is the live region container present in the DOM before the first message is written into it?
- Is the region hidden with a clipping technique rather than display:none or visibility:hidden?
- Is anything assertive that is not genuinely urgent?
- If the same message can occur twice in a row, will the second occurrence be announced?
Check the WCAG 2.2 criteria component libraries most often fail. (blocking)
- 2.4.7 and 1.4.11: does every focus indicator reach 3:1 against both the component and the surrounding background?
- 2.4.11: can a sticky header or footer completely cover a focused element at any scroll position?
- 2.5.8: is any pointer target smaller than 24 by 24 CSS pixels without qualifying spacing?
- 1.4.13: is hover or focus content dismissible with Escape, hoverable, and free of timeouts?
- 1.4.11: do input borders, unchecked control outlines, and meaningful icons reach 3:1?
Confirm a manual assistive-technology pass was performed.
- Was the component operated end to end with the keyboard only?
- Was it operated with a screen reader, and did the announced role match what it actually does?
- Was every state change — expansion, selection, error, completion — announced?
- Did the automated checker pass, and which categories of defect could it not have detected here?
Run the project accessibility linter or axe test suite. Passing is necessary but far from sufficient.
npm run test:a11y
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/keyboard-contracts.md— Exactly which keys must do what, and which ARIA attributes are required, for each composite widget a component library ships?references/focus-recipes.md— How do I actually implement a focus trap, restore focus safely, choose between roving tabindex and aria-activedescendant, and handle focus on route changes and deletions?