Command Palette

Search for a command to run...

Hectal
PHASE 18Intermediate ~32 min· topic 4 of 5

Topic 18.4

Accessible Interactive Components

In one line

Dialogs, disclosures, tabs, menu buttons and comboboxes each have a standard keyboard pattern in the WAI-ARIA Authoring Practices; build them with the right roles, states and keys, prefer native elements where they exist, and reach for a tested headless library for the hard ones.

Think of it like this

Traffic signals. Every city uses the same colours and the same rules, so a driver from anywhere knows what to do. If one town invented its own signals (purple means stop), visitors would crash. Keyboard and screen reader users rely on the same idea: a tab list always moves with arrow keys, a menu always closes with Escape. When your custom widget follows the shared rules, people already know how to use it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

WAI-ARIA Authoring Practices (APG)
The W3C guide that describes the roles, states and exact keyboard behaviour for common widgets.
Widget
An interactive piece of UI made of several parts, like tabs or a combobox.
Roving tabindex
Only one item in a group has tabIndex={0}; arrow keys move it. The group is one stop in the Tab order.
Automatic / manual activation
Tabs that select on focus versus tabs that need Enter or Space to select.
aria-activedescendant
Points (by id) to the option that is highlighted, while real focus stays on the input.
Disclosure
A button that shows and hides content, marked with aria-expanded.
Menu button
A button that opens a list of actions (role="menu"), operated with arrow keys.
Combobox
A text field with a popup list of suggestions you can move through with arrow keys.
Headless library
A library that provides behaviour, state and accessibility for widgets but leaves all styling to you.

Step by step

01Pick the pattern from what the widget does

Shows or hides one region → disclosure (or <details>). Switches between views in the same place → tabs. Opens a list of actions → menu button. Picks a value from suggestions while typing → combobox (or <select>/<datalist> if they're enough). Blocks the page until answered → modal dialog (<dialog>). Navigation links in a dropdown → disclosure with a list of links, not a menu.

Pick the pattern from what the widget doesdiagram
Rendering diagram…

02Model the state, derive the ARIA

Keep the minimum state in React (which tab is active, whether the menu is open, which option is highlighted) and compute every attribute from it during render: aria-selected={i === active}, tabIndex={i === active ? 0 : -1}, aria-expanded={open}. Then the announced state can never drift from what's on screen.

03Handle keys on the container

Put one onKeyDown on the tablist or menu, map keys to a new index, call e.preventDefault() for keys you handle (so arrows don't scroll the page), and update state. Wrap with simple arithmetic: (i + 1) % n and (i - 1 + n) % n.

keys.jswhole filejavascript
function nextIndex(key, i, n) {
  switch (key) {
    case "ArrowRight": return (i + 1) % n;
    case "ArrowLeft":  return (i - 1 + n) % n;
    case "Home":       return 0;
    case "End":        return n - 1;
    default:           return null; // not ours: let the browser handle it
  }
}

04Move real focus with refs (or virtual focus with ids)

Tabs and menus move real DOM focus: keep an array of refs (a ref callback per item, Topic 8.2) and call .focus() on the new index, in the handler or in an effect after the state changes. Comboboxes keep focus in the input and move virtual focus: set aria-activedescendant to the highlighted option's id, built from useId.

Move real focus with refs (or virtual focus with ids)diagram
Rendering diagram…

05Test it with the keyboard, then use a library for the hard ones

Unplug the mouse (Topic 18.5). If arrows, Home, End, Escape, Enter and Space do what the APG says, and focus is always visible, you're close. For comboboxes, date pickers, drag and drop and nested menus, a headless library saves weeks and handles screen reader quirks you won't find on your own.

RadixTabs.jsx (not live: needs npm install @radix-ui/react-tabs)whole filejsx
import * as Tabs from "@radix-ui/react-tabs";

<Tabs.Root defaultValue="overview">
  <Tabs.List aria-label="Product details">
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="specs">Specs</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">A phone with a great camera.</Tabs.Content>
  <Tabs.Content value="specs">6.1-inch screen, 128 GB.</Tabs.Content>
</Tabs.Root>
// Roles, aria-selected, aria-controls, roving tabindex and arrow keys come built in.

Try it yourself

  1. 1

    Switch the tabs to manual activation

    Change the tabs so arrow keys only move focus, and Enter or Space selects. Hint: keep a focused index separate from active, move tabIndex={0} with focused, and let the button's onClick (which Enter and Space trigger) call setActive. Predict: does the panel change while you arrow across?

  2. 2

    Add type-ahead to the menu

    In onMenuKeyDown, when e.key.length === 1, find the next action (after focusIndex, wrapping) whose first letter matches e.key.toLowerCase() and focus it. Press d twice: it should go Duplicate → Delete.

  3. 3

    Remove the onMouseDown guard

    In the combobox, delete onMouseDown={(e) => e.preventDefault()} and click an option with the mouse. Predict what happens. (The input blurs first, onBlur closes the list, the option disappears before its click, and nothing is chosen.)

Code & diagrams

Tabs with roving tabindex and arrow keys New tab

Exactly one tab is ever in the Tab order, and it's always the selected one. Arrow keys wrap around. The panel has `tabIndex={0}` because it contains no focusable element, so keyboard users can still reach (and scroll) it. CSS styles the tabs straight from `aria-selected`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start       | focus: Overview | selected: Overview | in Tab order: [ 'Overview' ]
ArrowRight  | focus: Specs    | selected: Specs    | in Tab order: [ 'Specs' ]
ArrowRight  | focus: Reviews  | selected: Reviews  | in Tab order: [ 'Reviews' ]
ArrowRight  | focus: Overview | selected: Overview | in Tab order: [ 'Overview' ]
ArrowLeft   | focus: Reviews  | selected: Reviews  | in Tab order: [ 'Reviews' ]
End         | focus: Reviews  | selected: Reviews  | in Tab order: [ 'Reviews' ]
Home        | focus: Overview | selected: Overview | in Tab order: [ 'Overview' ]
Disclosure: by hand and with <details> New tab

Both behave the same for keyboard and screen reader users ("What is your return policy?, button, collapsed"). `<details>` needs no state; reach for the hand-made version when you need to control `open` from React (pass `open` and listen to `onToggle` on `<details>` for that too).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

closed: aria-expanded = false | panel hidden: true
opened: aria-expanded = true | panel hidden: false
details open after clicking summary: true
Menu button: arrows, Escape and focus return New tab

Enter and Space on the button already open the menu, because they fire a click on a real `<button>`. Arrow Up from the button opens on the last item; Escape returns focus to the button; Enter on an item runs it and closes. Letter keys (type-ahead) are left out to keep the example short: add them in Try it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

ArrowDown  → focus: Rename
ArrowDown  → focus: Duplicate
ArrowUp    → focus: Rename
ArrowUp    → focus: Delete
Escape     → focus: Actions ▾
ArrowUp    → focus: Delete
chose Delete
Enter      → focus: Actions ▾
Combobox: focus stays in the input, aria-activedescendant moves New tab

Focus never leaves the input, so typing always works; the highlighted option is announced through `aria-activedescendant`. `onMouseDown` with `preventDefault` stops the input from blurring (and closing the list) before an option's click fires. Escape closes an open list; pressed again on a closed list, it clears the field. A production combobox needs more (scrolling the active option into view, result counts in a live region), which is why libraries exist.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

type a     | expanded: true | active: - | value: a | focus on input: true
ArrowDown  | expanded: true | active: Apple | value: a | focus on input: true
ArrowDown  | expanded: true | active: Apricot | value: a | focus on input: true
Enter      | expanded: false | active: - | value: Apricot | focus on input: true
Escape     | expanded: false | active: - | value:  | focus on input: true

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Put every tab in the Tab order

In the tabs example, replace tabIndex={i === active ? 0 : -1} with nothing (all tabs are plain buttons) and remove the onKeyDown.

terminal
$ Keyboard
── what you'll see ──
Tab stops on Overview, Specs and Reviews before reaching the panel. Arrow keys do nothing. Screen readers still say "tab, 1 of 3" and users press arrows, which don't work.

Break #2

Use role="menu" for site navigation

Turn the header's "Products" dropdown of links into role="menu" with role="menuitem" links.

terminal
$ Screen reader
── what you'll see ──
Users hear "menu" and expect an application menu: arrow keys only. Tab now closes the dropdown, link-list shortcuts no longer find the links, and the links are announced as menu items instead of links.

Myth vs fact

Myth

Adding the right roles makes a custom widget accessible.

Fact

Roles are half the job. The keyboard behaviour, focus movement and state attributes the role implies must all work, or the widget is worse than an unlabelled one.

Myth

Every dropdown is a menu.

Fact

Navigation dropdowns are disclosures with links; value pickers are <select> or listboxes/comboboxes. role="menu" is only for lists of actions.

Myth

Headless libraries are only for people who can't write widgets.

Fact

They encode years of screen reader and browser testing (mobile, RTL, virtual cursors). Senior teams use them precisely because the edge cases are endless.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Roving tabindex versus aria-activedescendant: roving moves real DOM focus (best support, scrolls items into view, needed when items contain other controls); activedescendant keeps focus on one element (needed when the user must keep typing, as in a combobox). Grids and listboxes can use either.

  • ▸

    React's ref callbacks (and React 19's ref cleanup functions) are the clean way to keep an array of item refs for roving focus. With a changing list, key the refs by id (a Map) instead of index so removals don't shift focus to the wrong item.

  • ▸

    The APG examples are reference implementations, not production components: the APG itself warns that screen reader support for some patterns (notably comboboxes) varies. Test with real assistive technology, and keep the native element when it's good enough: a <select> is accessible everywhere, including mobile.

  • ▸

    Typeahead, focus-visible styling, scroll-into-view, RTL (Left and Right swap meaning in right-to-left languages), touch screen readers (swipe gestures don't send arrow keys, so every action must also be reachable as a focusable element) and virtual-cursor behaviour are what make libraries like React Aria large. Read their docs to learn the edge cases even if you don't use them.

Remember this

  1. 1

    The shared rulebook is the WAI-ARIA Authoring Practices Guide (APG) from the W3C. For each common widget it lists the roles, the states and properties, and the exact keyboard interaction. Screen readers announce a widget's role ("tab, 2 of 3, selected"), and that announcement is a promise: users will press the keys that role implies. Here are the five patterns you'll build most, with the keys the APG specifies.

  2. 2

    Dialog (modal): role="dialog" with aria-modal="true" and a name (aria-labelledby on its heading). On open, focus moves inside. Tab and Shift+Tab cycle through the dialog's focusable elements only (wrapping at the ends). Escape closes it. On close, focus returns to the opener. The native <dialog> with showModal() implements all of this (Topic 18.2). For a non-modal popup, use show() or the Popover API.

  3. 3

    Disclosure (show/hide): a <button> with aria-expanded (true/false) and optionally aria-controls pointing to the content's id. Enter and Space toggle it, which a real button gives you. The content is shown or hidden (hidden or not rendered). The native <details>/<summary> pair is a complete disclosure with zero JavaScript; use it for FAQs. Disclosures are also the right pattern for site navigation dropdowns, not role="menu".

  4. 4

    Tabs: a container with role="tablist" (and a name), each tab a role="tab" with aria-selected and aria-controls → its panel; each panel role="tabpanel" with aria-labelledby → its tab. Only the selected tab is in the Tab order (tabIndex={0}, others -1): this technique is called roving tabindex. Tab enters the tab list on the selected tab and the next Tab goes into the panel. Left/Right Arrow move between tabs (wrapping from last to first), Home/End jump to the first/last. With automatic activation a tab is selected as soon as it gets focus (good when panels show instantly); with manual activation arrows only move focus and Enter/Space select. Vertical tab lists (aria-orientation="vertical") use Up/Down.

  5. 5

    Menu button: a <button> with aria-haspopup="menu" and aria-expanded, controlling a role="menu" list of role="menuitem" elements (each tabIndex={-1}, focused by code). Enter, Space or Down Arrow open the menu and focus the first item; Up Arrow opens it and focuses the last. Inside: Down/Up Arrow move (wrapping is optional), Home/End jump, Enter activates the item and closes, Escape closes and returns focus to the button, Tab closes and moves on, and typing a letter moves to the next item starting with it. Use role="menu" only for app-style action menus (Rename, Duplicate, Delete): screen readers switch modes inside menus, and users expect only menu items there.

  6. 6

    Combobox: a text input with role="combobox", aria-expanded, aria-controls → a role="listbox" popup of role="option" items, and aria-autocomplete="list" when the list filters as you type. DOM focus stays in the input (so typing keeps working) while Down/Up Arrow move a *virtual* focus through the options, announced via **aria-activedescendant** (the id of the highlighted option) and aria-selected. Enter accepts the highlighted option and closes; Escape closes the list (and, if it's already closed, may clear the input). It's the hardest pattern: screen reader support varies, which is why teams usually use a headless library: a library that supplies behaviour, state and ARIA but no styles. Well-known ones: React Aria (Adobe), Radix UI Primitives, Headless UI (Tailwind Labs), Ariakit and Base UI (Topic 11.5). Native alternatives: <select> and <input list> with <datalist> (limited styling).

Explain it without notes

01

Describe the keyboard interaction of the WAI-ARIA tabs pattern and how roving tabindex implements it in React.

02

When would you use a disclosure, a menu button and a combobox? Give a native alternative for each where one exists.

03

Why does a combobox use aria-activedescendant instead of moving focus into the list?

04

What does a headless component library give you, and name two.

Practice

01

Write an accessible Accordion from three disclosures where opening one closes the others.

02

Add Home and End support to the menu button example.

Trade-offs

  • ↔

    Building widgets yourself teaches the patterns and keeps bundles small, but every edge case is yours to find. For anything beyond disclosure and simple tabs, a headless library is usually cheaper over the life of a product.

  • ↔

    Automatic tab activation is faster to use; manual activation is better when showing a panel is slow (it fetches data), because arrowing across tabs would trigger a fetch for each.

Done when you can

  • I can name the roles, states and keys of the dialog, disclosure, tabs, menu button and combobox patterns.

  • I can build tabs with roving tabindex and arrow keys.

  • I can build a menu button that returns focus on Escape.

  • I can explain aria-activedescendant and when to use it.

  • I know when native elements are enough and when to use a headless library.