accessible-widgets
Builds and repairs keyboard- and screen-reader-accessible interactive React components to the WAI-ARIA Authoring Practices (APG) - modal dialogs, menu buttons, comboboxes / autocomplete with a listbox
SKILL.mdcatalog/skills/react/accessible-widgets/SKILL.md
GitHubAccessible interactive widgets in React
The WAI-ARIA Authoring Practices Guide (APG) is the contract assistive-technology users rely on. A role
is a promise: role="menu" tells a screen reader user that arrow keys work, Escape closes, and focus is
managed for them. Ship the whole promise or don't make it. Bad ARIA is worse than no ARIA, because it
lies to the user about how the page behaves.
Work in this order: pick the right element or pattern, apply its contract exactly (roles, states, keys, focus), implement it with the React mechanics below, then prove it with role- and focus-based tests.
1. Pick the element before writing ARIA
| You need | Use | Not |
|---|---|---|
| An action | <button type="button"> |
div/span with onClick |
| Navigation | <a href> |
a button that sets location |
| Show/hide a section | disclosure: <button aria-expanded> + region, or <details>/<summary> |
a menu |
| Site navigation dropdown | disclosure button + list of links | role="menu" (menus are for app-style action lists) |
| One value from a short fixed list | native <select> |
a custom listbox |
| Free text with suggestions | ARIA combobox (combobox.md); <input list> + <datalist> if you can live with browser styling |
role="listbox" alone |
| A list of actions behind a button | menu button (menu-button.md) | ul of clickable li |
| Layered panels, one visible | tabs (tabs-and-disclosure.md) | links that look like tabs, if they navigate |
| Blocking task over the page | <dialog> + showModal() (dialog.md) |
a div overlay with role="dialog" you trap by hand, unless you must |
Native elements bring keyboard support, focusability and semantics for free. Every custom widget you write instead owes the user all three.
2. Contract every composite widget owes
Checklist. Run it against any widget before calling it done.
- One Tab stop per composite. A tablist, menu, listbox or toolbar is one stop in the page Tab
sequence. Arrow keys move inside it. Items inside get
tabIndex={-1}; never0on every item. - One focus strategy, chosen on purpose (table below). DOM focus or
aria-activedescendantmust always identify the active item; a CSS highlight alone is invisible to assistive tech. - States are present in both directions.
aria-expanded="false"when closed, not omitted: an absentaria-expandedmeans "not expandable". In React pass a boolean (aria-expanded={open}renders"true"/"false"), neveraria-expanded={open || undefined}. - Every IDREF resolves.
aria-controls,aria-labelledby,aria-activedescendantmust name an element that exists right now. Generate ids withuseId(); hard-coded ids break the second instance on the page. - Everything interactive has a name: the widget container (
tablist,menu,listbox,dialog) viaaria-labelledbyoraria-label, inputs via<label>. - Keys by
event.key('ArrowDown','ArrowUp','ArrowLeft','ArrowRight','Home','End','Enter',' ','Escape','Tab').keyCode/whichare deprecated.preventDefault()only keys you handle (arrows and Space scroll the page otherwise). In text inputs, ignore keys whileevent.nativeEvent.isComposing(IME input). - Focus moves are explicit. Opening moves focus into the popup; closing by the user returns it
to the invoker; if the invoker is gone, move it somewhere logical, never to
<body>. - Hidden means hidden. Closed popups and inactive panels are unmounted or
hidden, so they are neither read nor tabbable. - Mouse and keyboard share one state machine. Hover may move the highlight; click and Enter
run the same
activate().
Roving tabindex or aria-activedescendant
| Roving tabindex | aria-activedescendant |
|
|---|---|---|
| How | active item tabIndex=0 (or -1 + .focus()), others -1, call .focus() on move |
DOM focus stays on the container or input; it carries aria-activedescendant="<active id>" |
| Use for | tabs, menus, toolbars, radio groups, grids | combobox popups (required: typing must keep going to the input), listboxes with huge virtual lists |
| Free | browser scrolls the item into view, :focus-visible styling |
no focus churn, input keeps caret and IME |
| You must | keep refs to items and focus after render | style the active item yourself, scroll it into view, keep the id pointing at a mounted element |
The referenced element must be a descendant of the focused element or be owned by it (aria-owns),
or, for a combobox, live in the popup it aria-controls.
3. Pattern contracts
Unmarked rules come from the APG pattern or the ARIA 1.2 Recommendation; "(APG example)" marks behaviour the APG's reference example implements that the pattern text leaves open, and "(optional)" marks extras. Implement optional behaviour when the product asks, never instead of a rule.
Disclosure
<button aria-expanded={open} aria-controls={regionId}>(aria-controls optional), panelhiddenwhen closed. Enter and Space toggle; a native button already does both.
Tabs
role="tablist"(named), childrenrole="tab", panelsrole="tabpanel".- Selected tab
aria-selected="true", every other tabaria-selected="false". - Each tab
aria-controlsits panel; each panelaria-labelledbyits tab. Render all panels withhidden, or putaria-controlsonly on tabs whose panel is mounted. - Tab enters the list on the selected tab and the next Tab leaves the list (to the panel).
- Left/Right Arrow move to previous/next tab and wrap. Home/End (optional) go to first/last.
- Automatic activation (selection follows focus) is recommended when panels render instantly;
otherwise manual activation with Enter/Space. Vertical tablist:
aria-orientation="vertical"and Up/Down instead. - Panel
tabIndex={0}when it has no focusable content of its own.
Menu button and menu
- Trigger: a
buttonwitharia-haspopup="menu"(or"true") andaria-expandedtrue/false;aria-controlsto the menu is optional. - Popup:
role="menu"labelled by the button (aria-labelledby) oraria-label; children aremenuitem/menuitemcheckbox/menuitemradio. Nolior wrapper with an implicit role betweenmenuand its items: use<ul role="menu"><li role="menuitem">, orrole="none"on wrappers. - Enter or Space on the button: open and focus the first item. Down Arrow (optional): first item; Up Arrow (optional): last item.
- In the menu: Down/Up move to next/previous (wrapping is optional; if you don't wrap, Home/End are required). Enter activates the item and closes the menu; focus then returns to the button (APG example). Escape closes and returns focus to the button. Tab closes the menu and focus moves on normally. Printable characters may jump to the next item starting with that letter (optional).
- Items are never Tab stops (
tabIndex={-1}).
Combobox with listbox popup (ARIA 1.2)
role="combobox"goes on the<input>itself, labelled by its<label>. The ARIA 1.0/1.1 shapes (combobox on a wrapperdivwitharia-owns, or a textbox inside a combobox) are non-conforming now; replace them.aria-expandedtrue/false (required),aria-controls= listbox id (required by ARIA 1.2 and the APG; set it at least while expanded),aria-autocomplete="list"when typing filters suggestions ("both"with inline completion,"none"if the list never changes). Implicitaria-haspopupislistbox; set it only for grid, tree or dialog popups.- Popup
role="listbox", childrenrole="option". DOM focus stays on the input; the input'saria-activedescendantnames the highlighted option, and that option alone hasaria-selected="true"(selection follows focus in a combobox listbox). - Down Arrow highlights the first (or next) option; Up Arrow the previous (or last). Enter accepts
the highlighted option, puts its text in the input, closes the popup and keeps focus in the
input;
preventDefault()it so it doesn't also submit a surrounding form. Escape closes the popup (clearing the input on a second Escape is optional). Printable keys keep editing the input. Alt+Down/Alt+Up open/close (optional). - Options and any open button are excluded from the Tab sequence.
Modal dialog
- Prefer native
<dialog>opened withshowModal(): the browser makes everything else inert, puts the dialog in the top layer, closes it on Escape (cancelthencloseevents), moves focus in, and on close returns focus to the element that was focused before it opened. - Custom dialogs must do all of that by hand:
role="dialog",aria-modal="true", a name viaaria-labelledby(visible title) oraria-label, focus inside on open, Tab and Shift+Tab cycle inside, Escape closes, focus returns to the invoker, backgroundinert. - Initial focus: first focusable control by default; a
tabIndex={-1}heading or first paragraph when content is long or structured; the least destructive button for irreversible actions. - In React,
autoFocusdoes not choose a native dialog's initial focus: on the client React callsfocus()once at mount and never writes theautofocusattribute, so the browser's dialog focusing steps can't see it. Call.focus()on the intended element right aftershowModal(). - Include a visible close/cancel button in the Tab sequence. Only mark a dialog modal if it really blocks the page, for everyone.
Full code and edge cases: dialog.md, menu-button.md, combobox.md, tabs-and-disclosure.md.
4. React mechanics that decide whether it works
Focus after render, not before. The element you want to focus often does not exist yet in the handler that opens the popup. Move focus in an effect keyed on the state that rendered it:
useEffect(() => {
if (expanded) optionRefs.current[focusIndex]?.focus()
}, [expanded, focusIndex])
React flushes effects from discrete events (key presses, clicks) before the next event, so this is
synchronous enough for keyboard users. Don't reach for setTimeout/requestAnimationFrame unless
something (an animation, a portal mounted later) really requires it; deferred focus can steal focus
back after the user has already tabbed away.
Refs to many items. Keep an array or Map of elements filled by callback refs:
ref={(el) => { optionRefs.current[i] = el }}. As of React 19 a callback ref may return a cleanup
function, so TypeScript rejects implicit returns (ref={(el) => (x = el)}): use a block body.
Function components receive ref as a normal prop in React 19, so new components don't need
forwardRef.
Ids from useId(). Call it once per instance and derive suffixes (${id}-popup,
${id}-row-${row.id}). The format is an implementation detail (React 19.2 emits _r_1_;
19.1 used «r1»; earlier versions used :r1:). Never parse it, and pass it through CSS.escape()
before using it in a selector. Never use it for list keys.
inert is a boolean prop in React 19 (<main inert={dialogOpen}>). React 18 needed inert="".
Inert subtrees can't be focused, clicked or read; it is the right tool to block the page behind a
custom modal or an off-canvas drawer.
Portals. React events bubble through the React tree, so a parent's onKeyDown sees keys from a
portaled menu; the DOM, focus order and Tab order follow the DOM tree. A popup portaled to the end of
<body> is not "after" its trigger for Tab, so manage focus explicitly or render the popup next to
its trigger. Native top layer (<dialog>, popover) avoids z-index portals altogether.
Blur and pointer. Close on blur only when focus left the widget:
if (!root.contains(event.relatedTarget as Node)) close(). In a combobox, preventDefault() on the
option's mousedown so the input never blurs before the click lands.
Derive, don't duplicate. aria-expanded, hidden and the rendered popup must come from the same
state variable, and the active option must be derived from the current suggestions so it can never
point at an unmounted element.
5. Anti-patterns and fixes
| Anti-pattern | Why it fails | Fix |
|---|---|---|
<div onClick> as a button or tab |
not focusable, no role, no Enter/Space | <button type="button"> (with role="tab" / "menuitem" where the pattern says) |
| Every tab/menu item tabbable | Tab walks through items instead of leaving the widget | roving tabindex: one 0, the rest -1 |
Arrow handler updates state but never calls .focus() |
screen reader and keyboard focus stay on the old item | focus in an effect, or use aria-activedescendant |
aria-expanded={open || undefined} |
closed state reads as "not expandable" | aria-expanded={open} |
<ul role="menu"><li><button role="menuitem"> |
li (listitem) between menu and item breaks the required structure |
li role="menuitem", or li role="none" |
Combobox role on a wrapper div, aria-owns |
ARIA 1.1 shape, no longer conforming | role="combobox" on the input, aria-controls to the listbox |
| Moving DOM focus into combobox options | typing stops reaching the input, caret is lost | keep focus on the input, use aria-activedescendant |
| Highlight only as a CSS class | assistive tech can't see it | aria-activedescendant + aria-selected="true" on the option |
Hard-coded id="listbox" |
second instance points at the first instance's popup | useId() |
| Escape closes but focus is lost | keyboard user is dumped at the top of the page | return focus to the trigger on every user-initiated close |
| Menu stays open on Tab | open popup floats over content the user moved to | close on Tab (and on blur outside) |
role="menu" for site navigation |
screen readers switch to application keys, links behave oddly | disclosure button + list of links |
aria-hidden="true" on a focusable element or an ancestor of the dialog |
focus lands on something that "doesn't exist" | inert or hidden; keep the dialog outside any hidden subtree |
Positive tabIndex values |
rewrites the page's Tab order | 0 or -1 only |
keyCode === 13 |
deprecated, wrong for some layouts | event.key === 'Enter' |
6. Prove it with tests
Test the contract the way a user meets it: by role and name, by keys, by where focus is.
const user = userEvent.setup()
render(<ActionMenu actions={rowActions} onAction={handleAction}>Row actions</ActionMenu>)
const button = screen.getByRole('button', { name: 'Row actions' })
expect(button.getAttribute('aria-expanded')).toBe('false')
button.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('menuitem', { name: 'Duplicate' })).toBe(document.activeElement)
await user.keyboard('{Escape}')
expect(document.activeElement).toBe(button)
- To accept both focus strategies, compute the "effectively focused" element:
document.activeElement, or the element named by itsaria-activedescendant. - Run axe-core on the open and the closed state, with the WCAG A/AA tags and
color-contrastdisabled (jsdom has no layout). - Know the jsdom gaps before you trust a red or green test: as of jsdom 30 there is no
HTMLDialogElement.showModal()/close(), no Popover API, noscrollIntoView, nomatchMedia, and user-event's Tab ignoresinert. Native-dialog behaviour needs a browser test (Playwright, Vitest browser mode) or an explicit test double.
Details, helpers and an axe setup: testing.md.
References
- references/dialog.md: native
<dialog>component, custom fallback, focus rules. - references/menu-button.md: full menu button, typeahead, activedescendant variant.
- references/combobox.md: ARIA 1.2 autocomplete, select-only variant, pitfalls.
- references/tabs-and-disclosure.md: tabs (automatic and manual), disclosure, accordion.
- references/testing.md: Testing Library, user-event, axe-core, jsdom gaps.
- references/sources.md: the specifications and docs this skill was checked against.