Accessible components: modals, menus, tabs and comboboxes
Dialog focus trapping and returning focus, disclosure and menu patterns, tab and accordion semantics, combobox keyboard behaviour, and why a tested library beats hand-rolling.
Modals and dialogs
<dialog id="confirm" aria-labelledby="confirm-title" aria-describedby="confirm-body">
<h2 id="confirm-title">Delete this project?</h2>
<p id="confirm-body">This removes the project and everything in it. This cannot be undone.</p>
<button type="button" data-action="cancel">Cancel</button>
<button type="button" data-action="delete">Delete project</button>
</dialog>const dialog = document.getElementById("confirm");
let previouslyFocused = null;
function openDialog(trigger) {
previouslyFocused = trigger ?? document.activeElement;
dialog.showModal(); // focus moves in, escape closes, page is inert
dialog.querySelector("[data-action='cancel']").focus();
}
dialog.addEventListener("close", () => {
previouslyFocused?.focus(); // always return focus to the trigger
});
// the native dialog handles the trap, the escape key and inertness of the backgroundshowModal()on the nativedialogelement gives you focus trapping, Escape handling and background inertness for free. Reimplementing it in a div is where most modal bugs come from.- Focus must move into the dialog on open, and back to the element that opened it on close. Losing focus on close strands a keyboard user at the top of the page.
- The dialog must have a name. A dialog announced without its title forces the user to explore to find out what it is.
- Do not close a dialog on a background click when it contains unsaved input unless you confirm the loss.
Disclosure, menus and tabs
<!-- disclosure: two states, one button -->
<button type="button" aria-expanded="false" aria-controls="filters">
Filters <span class="count">(3)</span>
</button>
<div id="filters" hidden>...</div>
<!-- tabs: roles, ids and a single tab stop -->
<div class="tabs">
<div role="tablist" aria-label="Order details">
<button role="tab" id="tab-items" aria-selected="true" aria-controls="panel-items" tabindex="0">Items</button>
<button role="tab" id="tab-shipping" aria-selected="false" aria-controls="panel-shipping" tabindex="-1">Shipping</button>
</div>
<div role="tabpanel" id="panel-items" aria-labelledby="tab-items" tabindex="0">...</div>
<div role="tabpanel" id="panel-shipping" aria-labelledby="tab-shipping" hidden>...</div>
</div>| Component | Keys the user expects | Semantics |
|---|---|---|
| Disclosure | Enter or Space to toggle | aria-expanded plus aria-controls |
| Menu button | Enter or Space opens, arrows move, Escape closes | aria-haspopup="menu", role="menu" |
| Tabs | Left and Right arrows between tabs | tablist, tab, tabpanel |
| Accordion | Enter or Space per header | A heading containing a button |
| Combobox | Down arrow opens, Up and Down move, Enter selects, Escape closes | role="combobox" with aria-expanded |
| Toggle button | Enter or Space | aria-pressed |
role="menu"is not a navigation menu. Using it for site navigation breaks the expected arrow-key behaviour for no benefit - use a list of links.- An accordion header should be a heading element containing a button, so the structure is navigable by heading as well as operable.
- Tabs need a single tab stop: exactly one tab has
tabindex="0"and the rest are-1. Otherwise the user must tab through every tab to leave the widget.
Comboboxes and libraries
<label for="country">Country</label>
<input id="country" role="combobox" aria-expanded="false" aria-controls="country-list"
aria-autocomplete="list" autocomplete="country-name" />
<ul id="country-list" role="listbox" aria-label="Countries" hidden>
<li role="option" id="opt-nl" aria-selected="false">Netherlands</li>
<li role="option" id="opt-no" aria-selected="false">Norway</li>
</ul>
<!-- and the live region that announces how many results match -->
<p role="status" class="sr-only">2 results available</p>- The input keeps the focus the whole time; the list is a popup it controls, not a separate focus target.
aria-activedescendantpoints at the visually highlighted option so the user's typing is not interrupted.- The visible list and the accessibility tree must agree. Updating one and not the other is the classic combobox bug.
- Announce the result count in a live region, politely. Without it, a screen reader user does not know the list changed.
- Do not build this from scratch unless you must. Focus management, filtering, virtual scrolling and keyboard handling together are a serious engineering task.
💡
Use a component library that documents its keyboard and screen reader behaviour and has tests for it. Every pattern above has edge cases - nested dialogs, a tab that is removed while focused, a combobox whose options load asynchronously - that a hand-rolled version will not handle on the first pass.
FAQ
Should I use the native dialog element?
Yes, where it is supported - it handles focus trapping, Escape and background inertness correctly. For older environments use a library rather than writing the trap yourself.
When is a menu a menu?
When it is a list of actions triggered by a button. Navigation between pages is a list of links in a
nav landmark, which behaves completely differently.Related
Focus management, ARIA and testing Live regions and dynamic content announcements
Last refreshed 2026-09-18.