cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

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.

A pattern drawn as two layers. The top layer, ARIA attributes, holds what the screen reader is told: the structure, a tablist containing four tabs; the name, from aria-labelledby; and the states, aria-selected true on tab 3. Together they produce the announcement Pricing, tab, selected, 3 of 4. The bottom layer, your script, holds the keyboard and focus contract: Left and Right arrows, Home and End, and roving tabindex. ARIA does nothing for this layer.ARIA attributes: what the screen reader is toldStructurerole treetablist > tab, tab, tab, tabNamewhat it is calledaria-labelledby="plans-heading"Stateschange as you actaria-selected="true" on tab 3Your script: what actually happensKeys and focusthe contractLeft, Right, Home, End, roving tabindex
The top three parts only describe the widget. The bottom one makes it work, and no attribute does it for you: 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.

  1. Structure: which roles, and how they nest. Some roles have required owned elements: a listbox must contain options, a tablist must contain tabs, a menu must contain menuitems. Some have a required context: an option only means something inside a listbox.
  2. Name: every widget needs an accessible name, from aria-labelledby pointing at visible text, from aria-label, or from its content. A listbox called "listbox" tells the user nothing.
  3. 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.
  4. 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.

A matrix of eight patterns against four questions. Combobox: one tab stop yes, arrow keys yes, has a popup yes, focus model pointer, meaning aria-activedescendant. Listbox: yes, yes, no, either. Menu: yes, yes, yes, roving tabindex. Tabs: yes, yes, no, roving. Dialog: no, no, yes, focus moves into it. Disclosure: no, no, no, focus stays on the button. Tree: yes, yes, no, either. Grid: yes, arrows in 4 directions, no, either.one tab stoparrow keyshas a popupfocus modelComboboxyesyesyespointerListboxyesyesnoeitherMenuyesyesyesrovingTabsyesyesnorovingDialognonoyesmoves inDisclosurenononostaysTreeyesyesnoeitherGridyes4 waysnoeither
The composite widgets (combobox, listbox, menu, tabs, tree, grid) are one tab stop with arrows inside. Only the combobox needs the pointer model, 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.

PatternUsual focus modelWhy
Comboboxaria-activedescendant on the inputFocus must stay in the text field so the user can keep typing
ListboxEither; APG examples use aria-activedescendantOptions are simple text, so pointing works well; roving is fine too
Menu, menubarUsually roving tabindex; the APG shows bothItems run commands; real focus works in every screen reader
TabsRoving tabindexOnly the selected tab is a tab stop, so Tab goes from it to the panel
TreeEither; roving is more commonReal focus on a treeitem plays well with all screen readers
GridEither; roving is more commonCells may hold buttons or inputs that need real focus
Toolbar, radio groupRoving tabindexEach 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" containing role="option" elements. Group options with role="group" and a label. Options are plain text: no links or buttons inside.
  • Name: aria-labelledby pointing at the visible label.
  • States: aria-selected="true" on the chosen option. For many choices, add aria-multiselectable="true" to the listbox and set aria-selected on 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.

html
<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>
An ARIA 1.2 combobox with the popup open and the second option active
  • aria-expanded tells the user the popup is open. Set it to false, not remove it, when closed.
  • aria-controls points at the popup's id. Older versions used aria-owns for this; 1.2 uses aria-controls.
  • aria-autocomplete says what typing does: list shows suggestions, inline completes the text in the field, both does both, none means the list does not change as you type.
  • aria-activedescendant names 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 a grid (rows with several cells), a tree, or a dialog. Set aria-haspopup on the input when the popup is not a listbox; listbox is the default.
A sequence diagram with four lifelines: User, Input, Listbox, and Reader, the screen reader. The user types li; the input filters the listbox to 3 options and sets aria-expanded true; a status region tells the reader 3 suggestions. The user presses Down; the input sets aria-activedescendant to opt-1; the reader says Lisbon, 1 of 3. The user presses Enter; the input fills in Lisbon, closes the popup, and sets aria-expanded false. Focus stays on the input the whole time.UserInputListboxReadertypes lifilter, expanded3 suggestionsDown arrowpoint at opt-1Lisbon, 1 of 3Enterclose popup
Every arrow starts or ends at the input, because DOM focus never leaves it. The listbox is only pointed at, through aria-controls and aria-activedescendant.

The combobox keyboard contract

KeyPopup closedPopup open
Down ArrowOpens the popup; may highlight the first optionHighlights the next option
Up ArrowOptional: opens and highlights the last optionHighlights the previous option
EnterSubmits the form, as a normal input wouldAccepts the highlighted option, closes the popup
EscapeOptional: clears the fieldCloses the popup; the text stays
Alt+DownOpens the popup without moving the highlightNothing extra
Typing, Left, Right, Home, EndEdit the text, as in any inputEdit 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" containing role="tab" elements (use buttons), and one role="tabpanel" per tab.
  • Name: the tablist gets aria-label or aria-labelledby; each panel gets aria-labelledby pointing at its tab, so entering the panel announces "Specs, tab panel".
  • States: aria-selected="true" on the current tab, false on the others. Each tab's aria-controls points at its panel. Hidden panels get the hidden attribute.
  • 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 panel tabindex="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.
A wireframe of a product page with three tabs, Overview, Specs, and Reviews, and one panel. Notes on the right label the parts: the row is role tablist with an aria-label; the Specs tab, highlighted, is role tab with aria-selected true and tabindex 0, while the other tabs have tabindex minus 1; the panel is role tabpanel, labelled by the Specs tab with aria-labelledby. Left and Right arrows move between tabs, and Tab moves from the selected tab into the panel.example.com/laptopOverviewSpecsReviewsSpecs panelTab from the tab lands hererole=tablistaria-labelSelected tab:role=tabaria-selectedtabindex=0role=tabpanelaria-labelledby
Only the selected tab is a tab stop (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?

Automatic activation

Arrow key moves focus and selects the tab in one step

The panel content must already be in the page or render in a few milliseconds

Pros

  • One key press per tab: fastest for users
  • Matches most desktop tab controls
  • What the APG recommends when panels show without delay

Cons

  • If a panel fetches data, arrowing past three tabs fires three requests
  • A slow panel makes arrowing feel sticky

Manual activation

Arrow key moves focus only; aria-selected stays on the old tab

Enter or Space selects the focused tab and shows its panel

Pros

  • No wasted work when panels are expensive
  • Users can scan the tab names before choosing

Cons

  • One extra key press per switch
  • The focused tab and the selected tab can differ, which you must style clearly

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.

QuestionTabsAccordionDisclosure
How many sections open at once?Exactly oneOne or manyOne 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 tabaria-expanded on each buttonaria-expanded on the button
Works on narrow screens?Poorly with many tabsWellWell
Native option?Nonedetails, with name for one at a timedetails 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, with aria-modal="true" when the page behind is blocked. Everything outside the dialog must be unreachable, which inert on the rest of the page or the native modal dialog gives you.
  • Name: aria-labelledby pointing at the dialog's heading. Add aria-describedby only 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".

html
<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>
A native modal dialog: the role, inert page, and Escape come from the browser

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 popover attribute 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" containing role="treeitem" elements. A parent item's children sit in a role="group" inside it. Leaves have no aria-expanded at all.
  • States: aria-expanded="true" or false on parent items; aria-selected for the selected item (or aria-multiselectable on the tree for many). If only part of the tree is in the DOM, add aria-level, aria-setsize, and aria-posinset so 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-activedescendant on the tree also works.
A file tree. The root treeitem src is expanded at level 1. It has three children at level 2: components, expanded, 1 of 3; lib, collapsed, 2 of 3; and index.ts, a leaf with no aria-expanded, 3 of 3. components has two children at level 3 inside a role group: Button.tsx and Tabs.tsx. A note lists the keys: Up and Down move through visible items, Right opens a node or goes into it, Left closes it or goes up to the parent.srcexpanded, level 1componentsexpanded, 1 of 3libcollapsed, 2 of 3index.tsleaf, 3 of 3role=groupButton.tsxleaf, level 3Tabs.tsxleaf, level 3Up, Down: moveRight: open, go inLeft: close, go up
Parents carry 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-rowcount on the grid and aria-rowindex on 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-selected on 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.

Two panels. On the left, a pricing table that users read: columns Free and Pro, rows Seats, 1 and 10, and Storage, 1 GB and 1 TB. It is a native table and the screen reader's own table commands move through it. On the right, a calendar week that users act in: day headers M, T, W, T, F and two rows of dates, 3 to 7 and 10 to 14, with 11 focused. It is role grid and arrow keys move focus between cells.table: users read itgrid: users act in itPricingFreeProSeats110Storage1 GB1 TBtable, th scopereader table keys, no scriptPick a dateMTWTF345671011121314grid, row, gridcellarrows move between days
Same rows and columns, different job. Read-only data stays a 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

  1. Does a native element do it? button, a, select, input with datalist, details, dialog, table, radio buttons in a fieldset. If yes, stop. You get the behavior, the platform's look on mobile, and years of browser and screen reader testing.
  2. 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).
  3. 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).
  4. 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.
A flowchart of four questions. Does a native element fit? If yes, use it: select, details, or dialog. If no: does it go to a page? If yes, use links, in disclosure navigation. If no: does it run a command? If yes, a menu button with a menu. If no: does the user pick a value? If yes, a combobox if they type, otherwise a listbox. If no, it shows content, data, or a blocking question: tabs, accordion, grid or table, tree, or dialog by shape.Native element fits?Use itselect, details, dialogyesnoGoes to a page?Linksdisclosure navigationyesnoRuns a command?Menu buttonmenu, menuitemyesnoPicks a value?Combobox, listboxcombobox if they typeyesno: shows or blocksTabs, accordion, grid, tree, dialog
Ask what the user does, in this order, and stop at the first yes. The last box is where tabs, accordion, grid, tree, and dialog live, picked by how many things open at once and in how many directions focus moves.

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 holdsPatternItem roleArrows or Tab?
Links to other pagesDisclosure navigationPlain links in a listTab
Commands on this pageMenu buttonmenuitemArrows
Values for a form fieldNative select, or select-only comboboxoptionArrows
A small form or rich contentNon-modal dialog or popoverWhatever the content isTab

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?

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

BugWhat the user hears or feelsFix
State out of sync: tab looks selected, aria-selected="false""Specs, tab, not selected" on the visible tabDerive the attribute and the style from the same state variable
aria-activedescendant points at an id that no longer existsSilence while arrowing through resultsStable ids from data; clear the attribute when the list empties
Role without keys: role="tablist", only Tab worksArrows do nothing; Tab skips the other tabsImplement the full keyboard contract, or drop the roles
aria-expanded on the popup, not the buttonThe button never says expanded or collapsedState goes on the control the user operates
Popup rendered in a portal far from its buttonTab from the open popup jumps to the page endMove 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: 0The screen reader reads every panelUse hidden, display: none, or inert
Duplicate ids from two instances of one componentLabels and aria-controls point at the wrong widgetGenerate 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

  1. 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.
  2. Role-based tests (Testing Library's getByRole, Playwright's getByRole): query the widget the way assistive technology sees it, press keys with user-event or page.keyboard, and assert on aria-selected, aria-expanded, and which element has focus. This is how you lock in the keyboard contract.
  3. 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.
tsx
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();
});
Locking in the tabs keyboard contract with Testing Library

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 key press goes first to the screen reader, which checks the role of the focused element. In browse mode the reader keeps the arrow keys and uses them to read text, links, and headings: your code never sees them. In focus mode the reader passes keys to the page, and your keydown handler runs. Focus mode starts on its own when focus lands on a combobox, listbox, grid, tree, menu, or text input.Browse mode: arrows readtext, never reach your codeKey pressScreen readerchecks the roleBrowse modeReader movesline by lineFocus modeYour keydownarrows reach the pageFocus mode starts on:combobox, listbox, grid,tree, menu, text inputs
Your arrow-key handler only runs in focus mode, and the role decides when the reader switches. A wrong role, or none, can leave your widget in browse mode where the keys never arrive.

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 label prop, an id prefix or useId for the ARIA references, onKeyDown handled inside the component, and a render prop for option content, while the component keeps the option role on the element it renders.
  • In the state model. The pattern's states are part of your state: isOpen drives aria-expanded, activeIndex drives aria-activedescendant or the roving tabindex, selectedId drives aria-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

LevelWhat a strong answer includes
JuniorUses native elements first; names the right roles; knows that ARIA adds no behavior and that keys must be handled
MidNames the APG pattern and its keyboard contract; picks roving tabindex or aria-activedescendant and says why; announces result counts; tests with getByRole
SeniorPushes 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, and table beat a custom pattern whenever they fit.
  • Use roving tabindex by default and aria-activedescendant when focus must stay put, as in a combobox.
  • Comboboxes follow the ARIA 1.2 shape: the role on the input, aria-controls to the popup, aria-activedescendant to 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?

Question 2

Why shouldn't a site's main navigation use role=menu?

Question 3

Walk me through the accessibility of an autocomplete input.

Question 4

When would you build a grid instead of using a table?

Question 5

How do you test that a custom widget follows its pattern?

Practice it

These design problems lean on aria widget patterns.

References

Read next