ARIA widget patterns
The tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
The problem: a widget that lies
A team builds a country picker. It is a div that shows the selected country, and a click opens a ul of 195 countries. It looks like every select box on the web. A sighted mouse user picks "Portugal" in two seconds.
A screen reader user tabs to it and hears "Portugal, group". Or nothing at all, because the div is not focusable. If a developer added tabindex="0", pressing Down Arrow scrolls the page instead of moving through countries. The screen reader has no idea a list opened, how many items it has, or which one is chosen.
Then someone adds role="menu" to the list because it "pops up like a menu". Now the screen reader switches into a mode for application menus, announces "menu, 195 items", and expects keys like a desktop File menu. The user is told the widget is something it is not. That is worse than no ARIA at all.
What ARIA is, and what it is not
ARIA, Accessible Rich Internet Applications, is a W3C spec of attributes that tell assistive technology what an element is and what state it is in. It has three kinds of attributes: a role says what the thing is (role="tab"), a state says something that changes often (aria-selected="true"), and a property says something more stable (aria-controls="panel-1").
ARIA changes only the accessibility tree, the parallel tree of names, roles, and states that the browser builds from the DOM and hands to screen readers through the operating system's accessibility API. It does not add behavior. role="button" does not make a div focusable, does not fire on Enter, and does not fire on Space. Every key in a custom widget is your code.
That is why the semantic HTML lesson starts with the first rule of ARIA: if a native element does the job, use it. A button, a select, a details element, and a dialog element bring their role, focus, keys, and states for free. ARIA is for the widgets HTML does not have: a combobox with filtering, a tab list, a tree, a data grid.
Why patterns, and not just attributes
A role is a promise. When a screen reader hears role="tablist", it tells the user "tab list, tab 2 of 4" and the user, who has used tabs in a hundred apps, reaches for the arrow keys. If your tabs only respond to Tab and Enter, the promise is broken and the user is stuck.
So the unit of work is not one attribute. It is a pattern: a role structure, the states that must stay in sync, and a keyboard contract, all three together. The ARIA Authoring Practices Guide (APG), published by the W3C's Web Accessibility Initiative, writes down these patterns for about 30 common widgets, each with working examples you can try with a keyboard and a screen reader.
WebAIM's yearly scan of the top one million home pages keeps finding that pages with ARIA have more detected accessibility errors on average than pages without it. ARIA is not the cause on its own, but ARIA used without a pattern is a common source of errors. Picking a pattern and following it fully is how you avoid that.
role="tab" never moved focus on its own.What this lesson covers
This lesson walks through the APG patterns you meet in real products: combobox, listbox, menu and menubar, tabs, dialog and alertdialog, disclosure and accordion, tree, and grid. For each one you get the roles, the states, the keys, how focus moves inside it, and the mistakes that show up in audits. Then you learn how to pick the right pattern for a design, and how to test it with real screen readers.
It builds on three published lessons: semantic HTML (native elements and the first rule of ARIA), keyboard navigation (roving tabindex and aria-activedescendant in general), and focus management (dialogs, inert, and where focus goes when content changes). Here we join them into full widgets.
Check your understanding · Exercise 1
You add role="button" to a div that has a click handler. What do you get? Select all that apply.
The mental model: a pattern is a contract with four clauses
Think of road signs. A red octagon means stop in every town, so drivers react without reading the small print. If one town painted its stop signs blue, drivers would sail through. ARIA patterns are the road signs of widgets: a tab list is announced the same way and answers to the same keys on every site, so users already know how to drive it.
Every APG pattern is a contract with four clauses. Learn to read any pattern through these four and you can build one you have never seen before.
- Structure: which roles, and how they nest. Some roles have required owned elements: a
listboxmust containoptions, atablistmust containtabs, amenumust containmenuitems. Some have a required context: anoptiononly means something inside alistbox. - Name: every widget needs an accessible name, from
aria-labelledbypointing at visible text, fromaria-label, or from its content. A listbox called "listbox" tells the user nothing. - States: the attributes that change as the user acts (
aria-expanded,aria-selected,aria-checked,aria-activedescendant). They must change in the same render as the visual change, every time. - Keyboard and focus: which keys do what, and how focus moves between the items. This is the clause most often skipped, and the one users notice first.
Why structure matters so much
The screen reader works out position from the structure. When focus lands on the third tab inside a tablist of four, it says "Pricing, tab, selected, 3 of 4". The count comes from the role tree, not from any attribute you wrote. Put a wrapper div with role="presentation" in between and that still works; put a focusable button or a div with its own role in between and the tree is invalid, and screen readers may lose the count or the role.
The same structure feeds tools. axe-core, the rule engine behind Lighthouse's accessibility audit and many test setups, has rules like aria-required-children and aria-required-parent that catch a broken role tree. They cannot check the keyboard clause, though. That part needs a human or an end-to-end test.
aria-activedescendant, because focus must stay in its input.The two ways to move inside a widget
Composite widgets, the ones made of many items, are a single tab stop. Inside, arrow keys move an active item. There are two ways to tell the browser and the screen reader which item is active. The keyboard navigation lesson covers both in general; here is how they map onto patterns.
Roving tabindex moves real DOM focus. The active item has tabindex="0", every other item has tabindex="-1", and on each arrow key you swap the values and call .focus() on the new item. The browser scrolls it into view, :focus-visible styles it, and every screen reader follows real focus.
aria-activedescendant keeps DOM focus on one element, the container or an input, and points at the active item by id: aria-activedescendant="opt-7". The screen reader announces opt-7 as if it had focus. You must style it yourself (it does not match :focus), scroll it into view yourself, and keep the id in the same document, since the reference does not cross a shadow root.
| Pattern | Usual focus model | Why |
|---|---|---|
| Combobox | aria-activedescendant on the input | Focus must stay in the text field so the user can keep typing |
| Listbox | Either; APG examples use aria-activedescendant | Options are simple text, so pointing works well; roving is fine too |
| Menu, menubar | Usually roving tabindex; the APG shows both | Items run commands; real focus works in every screen reader |
| Tabs | Roving tabindex | Only the selected tab is a tab stop, so Tab goes from it to the panel |
| Tree | Either; roving is more common | Real focus on a treeitem plays well with all screen readers |
| Grid | Either; roving is more common | Cells may hold buttons or inputs that need real focus |
| Toolbar, radio group | Roving tabindex | Each item is a real button or radio that should hold focus |
Last key: none yet. DOM focus is on: the option "Lisbon".
<ul role="listbox" aria-labelledby="city-label">
<li role="option" tabindex="0" aria-selected="true">Lisbon</li>
<li role="option" tabindex="-1" aria-selected="false">Lima</li>
<li role="option" tabindex="-1" aria-selected="false">Lyon</li>
<li role="option" tabindex="-1" aria-selected="false">Leeds</li>
<li role="option" tabindex="-1" aria-selected="false">Lagos</li>
</ul>Screen reader says: Lisbon, selected, 1 of 5
Your code must, on every key press:
- Swap tabindex: the new option gets 0, the old one -1.
- Call focus() on the new option.
- The browser scrolls it into view and :focus-visible draws the ring.
Check your understanding · Exercise 2
A listbox uses aria-activedescendant. Which of these become your job? Select all that apply.
Choosing a value: listbox and combobox
The most common custom widget is some kind of picker: a country, a user to assign, a font. Two APG patterns cover it. A listbox is a list of options that is always visible, where the user picks one or more. A combobox is an input, usually a text field, with a popup of suggestions that appears as you type or press Down Arrow.
Before either, ask if a native select works. It brings a role, a name from its label, type-ahead, and a picker that suits the platform, including the large touch picker on phones. A datalist attached to an input gives simple suggestions for free. Build a custom one only when you need rich options (avatars, two lines of text), filtering on the server, or styling the native control cannot reach.
Listbox
- Structure:
role="listbox"containingrole="option"elements. Group options withrole="group"and a label. Options are plain text: no links or buttons inside. - Name:
aria-labelledbypointing at the visible label. - States:
aria-selected="true"on the chosen option. For many choices, addaria-multiselectable="true"to the listbox and setaria-selectedon each option, true or false. - Keys: Up and Down move, Home and End jump to the ends, typing a letter jumps to the next option starting with it (type-ahead). In a multi-select list, Space toggles the active option and Shift+Down extends the selection.
A common mistake is a listbox of items that each contain a delete button. Options cannot hold interactive children: the screen reader flattens each option to its text, so the button is lost. If each row needs actions, the pattern is a grid, or a plain list of buttons.
Combobox, the ARIA 1.2 way
The combobox pattern changed twice. In ARIA 1.0 the role went on the text input; ARIA 1.1 moved it to a wrapper around the input; ARIA 1.2 (2023) moved it back onto the input. Many old tutorials and libraries still show the 1.1 wrapper. Follow 1.2: role="combobox" goes on the focusable input itself.
<label id="city-label" for="city">City</label>
<input id="city" type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="city-list"
aria-activedescendant="city-opt-2" />
<ul id="city-list" role="listbox" aria-labelledby="city-label">
<li id="city-opt-1" role="option">Lisbon</li>
<li id="city-opt-2" role="option" aria-selected="true">Lima</li>
<li id="city-opt-3" role="option">Lyon</li>
</ul>
<div role="status" class="sr-only">3 suggestions</div>
aria-expandedtells the user the popup is open. Set it tofalse, not remove it, when closed.aria-controlspoints at the popup'sid. Older versions usedaria-ownsfor this; 1.2 usesaria-controls.aria-autocompletesays what typing does:listshows suggestions,inlinecompletes the text in the field,bothdoes both,nonemeans the list does not change as you type.aria-activedescendantnames the highlighted option. Empty it when nothing is highlighted, and when the popup closes.- The popup is usually a
listbox, but 1.2 also allows agrid(rows with several cells), atree, or adialog. Setaria-haspopupon the input when the popup is not a listbox;listboxis the default.
aria-controls and aria-activedescendant.The combobox keyboard contract
| Key | Popup closed | Popup open |
|---|---|---|
| Down Arrow | Opens the popup; may highlight the first option | Highlights the next option |
| Up Arrow | Optional: opens and highlights the last option | Highlights the previous option |
| Enter | Submits the form, as a normal input would | Accepts the highlighted option, closes the popup |
| Escape | Optional: clears the field | Closes the popup; the text stays |
| Alt+Down | Opens the popup without moving the highlight | Nothing extra |
| Typing, Left, Right, Home, End | Edit the text, as in any input | Edit the text; the list filters |
Focus never leaves the input. That is why the combobox is the one pattern that needs aria-activedescendant: if you moved DOM focus to an option, the next letter the user types would land on the option, not in the field.
Two details separate a good combobox from a passable one. First, announce the number of results in a polite live region (role="status") after the list updates, debounced so it does not speak on every key press. Second, keep option ids stable across result updates, for example from the record's id, so a highlight on "Lima" still points at Lima when new results arrive. The autocomplete guide builds the full component, including racing requests.
Check your understanding · Exercise 3
A user has typed "li" and pressed Down twice. The second option is highlighted. Which attributes should be on the input right now? Select all that apply.
Showing and hiding: tabs, disclosure, and accordion
Three patterns hide some content and show it on demand. They look alike in a mockup, and the difference is in how many sections can be open and how the user moves between them.
Tabs
Tabs show exactly one of several panels in the same place: Overview, Specs, Reviews on a product page, or Code, Issues, Pull requests on a repository. The parts:
- Structure:
role="tablist"containingrole="tab"elements (usebuttons), and onerole="tabpanel"per tab. - Name: the tablist gets
aria-labeloraria-labelledby; each panel getsaria-labelledbypointing at its tab, so entering the panel announces "Specs, tab panel". - States:
aria-selected="true"on the current tab,falseon the others. Each tab'saria-controlspoints at its panel. Hidden panels get thehiddenattribute. - Focus: roving tabindex. Only the selected tab has
tabindex="0". Tab from the tab list goes into the panel. If the panel starts with text, not a focusable control, give the paneltabindex="0"so Tab lands on it. - Keys: Left and Right move between tabs and wrap at the ends; Home and End jump. For a vertical tab list, set
aria-orientation="vertical"and use Up and Down.
tabindex="0"). Arrows move between tabs; the next Tab goes into the panel, which is named after its tab.Tabs have one real design choice: automatic or manual activation. With automatic activation, moving to a tab with an arrow key also shows its panel. With manual activation, arrows only move focus and the user presses Enter or Space to show the panel.
Decision Automatic or manual tab activation?
What we pick: Use automatic activation when panels are already rendered or cheap, which covers most product pages and settings screens. Use manual activation when showing a panel triggers a network request or a heavy render, like a dashboard tab that loads charts.
Disclosure
A disclosure is one button that shows or hides one piece of content: "Show more", "Advanced options", a FAQ answer. It is the simplest pattern in the APG: a button with aria-expanded, and optionally aria-controls pointing at the content. Enter and Space toggle it. There is no role for the content and no arrow keys.
The native details and summary elements are a disclosure with no script. Browsers expose the open state. The name attribute, supported in all major browsers since 2024 (Chrome 120, Safari 17.2, Firefox 130), groups several details elements so only one stays open: an exclusive accordion with no JavaScript at all.
Accordion
An accordion is a stack of disclosures with headings. Each header is a real heading (h3, at the right level for the page) that contains a button with aria-expanded and aria-controls. The heading lets screen reader users jump between sections with the H key; the button does the toggling.
Panels may get role="region" with aria-labelledby pointing at their button, which makes each open panel a landmark. The APG advises against that when many panels can be open at once, because a page full of regions makes the landmark list useless. Up and Down between headers, Home and End, are optional extras; Tab already works.
| Question | Tabs | Accordion | Disclosure |
|---|---|---|---|
| How many sections open at once? | Exactly one | One or many | One section, open or closed |
| How do you move between headers? | Arrow keys (one tab stop) | Tab (each header is a tab stop) | Tab |
| What state attribute? | aria-selected on the tab | aria-expanded on each button | aria-expanded on the button |
| Works on narrow screens? | Poorly with many tabs | Well | Well |
| Native option? | None | details, with name for one at a time | details and summary |
Check your understanding · Exercise 5
A settings page has four sections that users often want to compare side by side. Which pattern fits? Select all that apply.
Interrupting: dialog and alertdialog
A modal dialog is a window over the page that the user must deal with before going back: a sign-in form, a confirm step, an image editor. The focus management lesson covers the focus side in depth (where focus goes on open, inert, returning focus on close) and the modal dialog guide builds one. Here is the pattern itself.
The dialog contract
- Structure:
role="dialog"on the container, witharia-modal="true"when the page behind is blocked. Everything outside the dialog must be unreachable, whichinerton the rest of the page or the native modal dialog gives you. - Name:
aria-labelledbypointing at the dialog's heading. Addaria-describedbyonly for a short message that should be read on open, not for a long form. - Focus on open: to the first focusable control in most cases; to the heading or the start of the content (with
tabindex="-1") when the dialog opens with text the user should read first. - Keys: Tab and Shift+Tab cycle inside the dialog. Escape closes it. On close, focus returns to the control that opened it.
The native dialog element opened with showModal() does most of this for you. It gets the dialog role, makes the rest of the page inert, puts it in the top layer above every z-index, closes on Escape through the cancel event, and returns focus to the opener on close in current browsers. Prefer it to a hand-built div with role="dialog".
<button type="button" id="rename-btn">Rename</button>
<dialog id="rename" aria-labelledby="rename-title">
<form method="dialog">
<h2 id="rename-title">Rename file</h2>
<label for="name">New name</label>
<input id="name" name="name" autofocus />
<button value="cancel" formnovalidate>Cancel</button>
<button value="save">Save</button>
</form>
</dialog>
<script>
const dialog = document.getElementById("rename");
document.getElementById("rename-btn")
.addEventListener("click", () => dialog.showModal());
</script>
Alertdialog
An alertdialog is a modal dialog whose purpose is an urgent message that needs a response: "Delete 12 files? This cannot be undone." The role tells assistive technology it is an alert, so screen readers can treat it like a system alert and read the message right away. Give it aria-describedby pointing at the message, so the user hears both the title and the question.
Where focus goes matters more here. For a destructive confirm, focus the least destructive button, Cancel, so an Enter pressed by habit does not delete anything. With a native dialog, add role="alertdialog" to the element and put autofocus on Cancel.
Things that are not dialogs
- A toast ("Message sent", "Undo") does not take focus. It is a live region:
role="status"for polite news,role="alert"for errors. If it has an Undo button, give users time to reach it and make it reachable from the keyboard. - A popover with a few links, like a user card on hover, is not modal. Use the
popoverattribute or a disclosure. It should close on Escape, but the page behind stays usable. - A cookie banner usually should not be modal either. A bottom bar that users can tab to and dismiss is less hostile, and it must not entirely hide the focused element (WCAG 2.2, success criterion 2.4.11, Focus Not Obscured).
Hierarchies and two dimensions: tree, grid, and table
The last two big patterns are the heaviest. A tree shows nested items you expand and collapse, like a file explorer. A grid is a set of cells you move between in two directions, like a spreadsheet or a calendar. Both are a single tab stop with a large keyboard contract, so build them only when the simpler options fail.
Tree view
- Structure:
role="tree"containingrole="treeitem"elements. A parent item's children sit in arole="group"inside it. Leaves have noaria-expandedat all. - States:
aria-expanded="true"orfalseon parent items;aria-selectedfor the selected item (oraria-multiselectableon the tree for many). If only part of the tree is in the DOM, addaria-level,aria-setsize, andaria-posinsetso position is still announced: "src, level 2, 3 of 7, expanded". - Keys: Up and Down move through the visible items; Right on a closed parent opens it, on an open parent moves to its first child; Left on an open parent closes it, on a child moves to its parent; Home and End jump; type-ahead jumps by first letter;
*optionally expands all siblings. - Focus: roving tabindex on treeitems is the common choice;
aria-activedescendanton the tree also works.
aria-expanded, leaves carry nothing. The children of components sit in a role="group", which is how the screen reader knows their level.Watch for one trap. A docs sidebar with nested sections is a tree in shape, but users only need to reach links. A nav with nested lists and disclosure buttons for the sections is easier to build and to use: Tab through the links, Enter on a button to expand. Use a real tree when users act on items in place, like selecting, renaming, or dragging files, as in VS Code's explorer.
Grid
A grid in ARIA is an interactive widget, not a look. It is for content where the user moves focus between cells: a spreadsheet, a date picker's month view, an inbox where each row has a checkbox, star, and actions. The structure is role="grid", role="row", and role="gridcell", with columnheader and rowheader for headers. A native table with these roles added works well because the structure is already there.
- Keys: all four arrows move by one cell; Home and End go to the start and end of the row; Ctrl+Home and Ctrl+End go to the first and last cell; Page Up and Page Down move by a screenful of rows.
- Cells with controls: if a cell holds one button, focus goes on the button itself. If a cell holds an editor, Enter or F2 enters it and Escape returns to cell navigation. While editing, the arrows move the text caret, not between cells.
- Large data: with virtualized rows, set
aria-rowcounton the grid andaria-rowindexon each row, so "row 5,341 of 10,000" is announced even though only 40 rows are in the DOM. The virtualization lesson covers the rest. - Selection and sorting:
aria-selectedon rows or cells;aria-sort="ascending"on the header of the sorted column.
Grid or table?
If users only read the data, use a plain table with th headers and scope. Screen readers have rich table commands for it (in NVDA and JAWS, Ctrl+Alt plus an arrow moves by cell and reads the header), and every cell's text is reachable in reading mode. Turning a read-only table into a grid takes those away and adds a keyboard contract the user did not ask for.
table, so screen reader table commands work; cells you move between and act on make a grid.Use a grid when cells are the thing you act on: a calendar where arrow keys move between days, a spreadsheet, a data grid with inline editing. The APG also has treegrid for rows that expand into child rows, like an email thread view or a file list with folders. It is the hardest pattern in the guide; reach for it last.
Check your understanding · Exercise 6
Which of these should use role="grid"? Select all that apply.
Picking the right pattern
Most ARIA bugs are not typos. They are the wrong pattern, chosen because of a word in the design file. "Dropdown", "menu", "tabs", and "popup" describe how something looks. The pattern depends on what the user is doing with it.
Four questions, in order
- Does a native element do it?
button,a,select,inputwithdatalist,details,dialog,table, radio buttons in afieldset. If yes, stop. You get the behavior, the platform's look on mobile, and years of browser and screen reader testing. - What does the user do with it? Go somewhere (links), run a command (button or menu), choose a value (select, listbox, combobox, radio group), reveal content (disclosure, accordion, tabs), answer a question that blocks the page (dialog), or work with structured data (table, grid, tree).
- How many things at once, and in how many directions? One panel open or many (tabs or accordion); one value or many (
aria-multiselectable); one direction (listbox, tree) or two (grid). - Where must focus stay? If the user must keep typing while choosing, it is a combobox and the focus model is
aria-activedescendant. Otherwise use roving tabindex.
Lab: from a UI description to a pattern
Pick a piece of UI from a real product below. The lab shows the pattern that fits, the roles and states you need, the keys you must handle, and the mistake teams most often make with it. Try to name the pattern before you look.
Which pattern fits?
Same look, different pattern
The same visual can need different patterns. A button with a chevron that opens a list can be four things, and a careful candidate names the difference out loud.
| What the list holds | Pattern | Item role | Arrows or Tab? |
|---|---|---|---|
| Links to other pages | Disclosure navigation | Plain links in a list | Tab |
| Commands on this page | Menu button | menuitem | Arrows |
| Values for a form field | Native select, or select-only combobox | option | Arrows |
| A small form or rich content | Non-modal dialog or popover | Whatever the content is | Tab |
Think first
The design calls the popup under the user's avatar a "menu". It holds Profile and Settings, which are pages, and Log out, which is an action. Which pattern do you build?
It is mostly navigation, so the simplest correct choice is a disclosure: a button with aria-expanded that shows a list with two links and a Log out button. Tab moves through them, and the links still behave like links, with URLs users can open in a new tab.
The menu button pattern would also be valid if you build its full keyboard contract, but it is more work, and it hides the fact that two of the items are links. Name both options and say why you picked the disclosure.
Failure modes and testing with screen readers
A pattern can pass every automated check and still be unusable. The role tree may be valid while the keys do nothing, or a state may update a frame late. This chapter lists what breaks in production and how to catch each kind of bug.
What breaks in production
| Bug | What the user hears or feels | Fix |
|---|---|---|
State out of sync: tab looks selected, aria-selected="false" | "Specs, tab, not selected" on the visible tab | Derive the attribute and the style from the same state variable |
aria-activedescendant points at an id that no longer exists | Silence while arrowing through results | Stable ids from data; clear the attribute when the list empties |
Role without keys: role="tablist", only Tab works | Arrows do nothing; Tab skips the other tabs | Implement the full keyboard contract, or drop the roles |
aria-expanded on the popup, not the button | The button never says expanded or collapsed | State goes on the control the user operates |
| Popup rendered in a portal far from its button | Tab from the open popup jumps to the page end | Move focus into it on open and back on close, or keep it next to the button in the DOM |
Hidden panels kept with only opacity: 0 | The screen reader reads every panel | Use hidden, display: none, or inert |
Duplicate ids from two instances of one component | Labels and aria-controls point at the wrong widget | Generate ids with useId or a prefix per instance |
Two cross-cutting issues deserve their own note. First, shadow DOM: ARIA id references like aria-labelledby and aria-activedescendant do not cross a shadow root today, so a web component library that puts the input in one shadow root and the listbox in another breaks the combobox. Proposals such as reference target are working their way through standards; until they ship everywhere, keep related parts in the same tree.
Second, mobile. Touch screen readers (VoiceOver on iOS, TalkBack on Android) navigate by swiping, not arrow keys. A pattern still has to work there: every item must be reachable by swipe, and double tap must activate it. Support for aria-activedescendant on mobile has been weaker than on desktop, which is one more reason to prefer roving tabindex outside of comboboxes.
Three layers of testing
- Automated rules (axe-core in unit tests, Lighthouse, the axe browser extension): catch broken structure, missing names, invalid attribute values, and duplicate ids. Cheap; run on every pull request. They find only part of real issues, and none of the keyboard ones.
- Role-based tests (Testing Library's
getByRole, Playwright'sgetByRole): query the widget the way assistive technology sees it, press keys withuser-eventorpage.keyboard, and assert onaria-selected,aria-expanded, and which element has focus. This is how you lock in the keyboard contract. - Manual screen reader passes: before release and after big changes, use the widget with a real screen reader. Only this tells you what users actually hear.
test("arrow keys move between tabs", async () => {
const user = userEvent.setup();
render(<ProductTabs />);
const overview = screen.getByRole("tab", { name: "Overview" });
await user.tab();
expect(overview).toHaveFocus();
await user.keyboard("{ArrowRight}");
const specs = screen.getByRole("tab", { name: "Specs" });
expect(specs).toHaveFocus();
expect(specs).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveAccessibleName("Specs");
await user.keyboard("{End}");
expect(screen.getByRole("tab", { name: "Reviews" })).toHaveFocus();
});
Which screen readers to test with
Screen readers and browsers come in pairs, and the pair matters because the browser builds the accessibility tree. Test the pairs your users use. The common ones:
- NVDA with Chrome or Firefox on Windows. Free, widely used, and a good first test.
- JAWS with Chrome on Windows. Common in workplaces and government.
- VoiceOver with Safari on macOS and iOS. Built in; the only realistic choice on Apple devices.
- TalkBack with Chrome on Android.
Windows screen readers have two modes. In browse mode (also called virtual cursor) the arrow keys read the page line by line and never reach your code. In focus mode keys go to the page.
When focus lands on a combobox, listbox, grid, tree, or menu, the reader usually switches to focus mode on its own, so your arrow handlers run. That switch is driven by the role, which is one more reason the role must be right.
A short script for each widget: reach it with Tab and listen to the name, role, and state. Operate it with the keys from its APG page. Check that every change is announced ("expanded", "selected", "3 of 8"). Leave with Tab and Escape and check where focus lands.
Compare with the APG's own example in the same pair: if the example speaks and yours doesn't, the bug is yours.
Check your understanding · Exercise 7
Which bugs would an axe-core scan catch? Select all that apply.
Using it in a system design interview
Frontend system design rounds often ask for a component: an autocomplete, a dropdown, a date picker, a modal, a data table, a tabs component, a file explorer. Each one maps to an APG pattern. Naming that pattern early, and getting it right, is one of the clearest signals that you have built real accessible UI.
When to bring it up
- During requirements. "It must be fully usable with a keyboard and a screen reader; I'll follow the APG combobox pattern." One sentence, said early, sets the bar.
- In the component API. Show that accessibility shapes the props: a required
labelprop, anidprefix oruseIdfor the ARIA references,onKeyDownhandled inside the component, and a render prop for option content, while the component keeps theoptionrole on the element it renders. - In the state model. The pattern's states are part of your state:
isOpendrivesaria-expanded,activeIndexdrivesaria-activedescendantor the rovingtabindex,selectedIddrivesaria-selected. One source of truth means they cannot drift. - In testing and rollout. Role-based tests for the keyboard contract, axe in CI, and a manual pass with NVDA and VoiceOver before launch.
What to draw
A small annotated markup sketch beats a paragraph. For an autocomplete, draw the input with its ARIA attributes, the listbox with option ids, and an arrow showing aria-activedescendant pointing from one to the other. Add a live region box for the result count. Interviewers remember a candidate who drew this.
How deep to go by level
| Level | What a strong answer includes |
|---|---|
| Junior | Uses native elements first; names the right roles; knows that ARIA adds no behavior and that keys must be handled |
| Mid | Names the APG pattern and its keyboard contract; picks roving tabindex or aria-activedescendant and says why; announces result counts; tests with getByRole |
| Senior | Pushes back on the wrong pattern (menu for navigation, grid for a read-only table); covers virtualization with aria-rowcount and aria-setsize; shadow DOM limits; mobile screen readers; a design system that ships tested patterns so product teams don't rebuild them |
Check your understanding · Exercise 8
You are asked to design a reusable Dropdown component for a design system. What is the best first move? Select all that apply.
Summary
- ARIA changes only what assistive technology is told. It adds no focus, no keys, no behavior: every key in a custom widget is your code.
- A pattern is a contract with four parts: role structure, accessible name, states kept in sync, and a keyboard contract. Half a pattern is worse than none.
- Native first:
button,select,details,dialog, andtablebeat a custom pattern whenever they fit. - Use roving tabindex by default and
aria-activedescendantwhen focus must stay put, as in a combobox. - Comboboxes follow the ARIA 1.2 shape: the role on the input,
aria-controlsto the popup,aria-activedescendantto the option. - Menus are for commands. Site navigation is links in a disclosure, and a row of page links is not a tab list.
- Pick the simpler pattern when two fit: disclosure over menu, table over grid, nested links over tree.
- Test in three layers: axe for structure, role-based tests for keys and states, real screen readers for what users hear.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between roving tabindex and aria-activedescendant, and when do you use each?
Both make a composite widget a single tab stop with arrow keys inside. Roving tabindex moves real DOM focus: the active item has tabindex="0", the rest -1, and each arrow key swaps them and calls focus(). The browser scrolls and styles focus for you, and every screen reader follows it.
aria-activedescendant keeps DOM focus on one element and points at the active item by id. You style and scroll the item yourself, ids must be stable and in the same DOM tree, and mobile support has been weaker.
Default to roving tabindex for tabs, menus, toolbars, trees, and grids. Use aria-activedescendant for a combobox, because focus must stay in the input so typing keeps working.
Question 2
Why shouldn't a site's main navigation use role=menu?
role="menu" means an application menu of commands. It replaces the link role, so users lose "link" announcements and link features like the links list. It switches screen readers into focus mode and promises arrow-key navigation that nav code rarely builds, and it forbids non-menu content like headings in a mega menu.
Navigation is links. Use a nav landmark with disclosure buttons (aria-expanded) that show lists of links, and Tab to move through them. The APG has a disclosure navigation example for exactly this.
Question 3
Walk me through the accessibility of an autocomplete input.
It is the APG combobox in the ARIA 1.2 shape: role="combobox" on the input, aria-expanded, aria-controls pointing at a listbox, aria-autocomplete="list", and aria-activedescendant naming the highlighted option, which also gets aria-selected="true".
Keys: Down opens and moves, Up moves back, Enter accepts and closes, Escape closes, Alt+Down opens without moving. Focus never leaves the input.
Details that matter: stable option ids from record ids, a debounced polite live region that says "8 results", clearing aria-activedescendant when the list closes or empties, and tests with getByRole("combobox") that press keys and check attributes.
Question 4
When would you build a grid instead of using a table?
Only when users act on cells: moving between days in a calendar, editing a spreadsheet, or working through rows of controls in a data grid. Then role="grid" gives one tab stop and arrow keys in two directions, with Enter or F2 to edit a cell.
If the data is read, use a native table with proper headers. Screen readers have table commands for it, and a grid would hide those behind a keyboard contract nobody asked for. For large virtualized data, either way, add aria-rowcount and aria-rowindex.
Question 5
How do you test that a custom widget follows its pattern?
Three layers. Automated rules like axe-core in CI catch structure problems: missing required children, missing names, duplicate ids. Role-based tests with Testing Library or Playwright query by role and name, press the keys from the APG contract, and assert focus and states.
Then a manual pass with real screen reader and browser pairs: NVDA with Chrome or Firefox, VoiceOver with Safari on macOS and iOS, and ideally JAWS and TalkBack. Compare with the APG example in the same pair.
Practice it
These design problems lean on aria widget patterns.
- Design an accordionEasy
- Design a tabs componentEasy
- Design a tooltipEasy
- Design a star rating widgetEasy
- Design a toast notification systemEasy
- Design a modal dialogEasy
- Design a pagination controlEasy
- Design breadcrumbsEasy
- Design a loading skeleton systemEasy
- Design a signature padEasy
- Design a like button for millions of usersEasy
- Design a recently viewed products featureEasy
- Design report exportEasy
- Design an autocompleteMedium
- Design a custom select dropdownMedium
- Design an image carouselMedium
- Design a date pickerMedium
- Design an infinite scroll listMedium
- Design a tag (multi-select) inputMedium
- Design a throttled progress bar queueMedium
- Design a file-explorer tree viewMedium
- Design a command paletteMedium
- Design a drag-and-drop reorderable listMedium
- Design an @-mention inputMedium
- Design a color pickerMedium
- Design an emoji and GIF pickerMedium
- Design a podcast audio playerMedium
- Design a podcast player with a synced transcriptMedium
- Design a nested comments threadMedium
- Design a notification centerMedium
- Design a customer support chat widgetMedium
- Design a product tourMedium
- Design permission-based UIMedium
- Design a grocery delivery slot pickerMedium
- Design a dropdown menuMedium
- Design a scheduling link like CalendlyMedium
- Design an online exam platformMedium
- Design an AI image generation studioMedium
- Design a data tableHard
- Design a virtualized listHard
- Design a calendar week viewHard
- Design a Kanban boardHard
- Design a combobox for 100,000 optionsHard
- Design a rich text editorHard
- Design a drag-and-drop form builderHard
- Design a video playerHard
- Design a secure payment formHard
- Design AirbnbHard
- Design a seat selection map for eventsHard
- Design Google SheetsHard
- Design Google CalendarHard
- Design Google DriveHard
- Design a code review diff viewerHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a browser-based music studioHard
- Design a design system for many teamsHard
- Design Google PhotosHard
- Design a browser video editorHard
- Design a writing assistant like GrammarlyHard
- Design a 3D product configuratorHard
References
- ARIA Authoring Practices Guide: Patterns Every pattern in this lesson, with working examples
- Developing a Keyboard Interface (APG) Roving tabindex and aria-activedescendant
- WAI-ARIA 1.2 specification Roles, states, properties, required owned elements
- Example Disclosure Navigation Menu (APG) The right pattern for site navigation
- MDN: WAI-ARIA roles Reference for every role
- WebAIM: The WebAIM Million Yearly data on ARIA use and errors
- Testing Library: ByRole queries Querying widgets by role and name in tests
Read next
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.