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 background
  • showModal() on the native dialog element 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>
ComponentKeys the user expectsSemantics
DisclosureEnter or Space to togglearia-expanded plus aria-controls
Menu buttonEnter or Space opens, arrows move, Escape closesaria-haspopup="menu", role="menu"
TabsLeft and Right arrows between tabstablist, tab, tabpanel
AccordionEnter or Space per headerA heading containing a button
ComboboxDown arrow opens, Up and Down move, Enter selects, Escape closesrole="combobox" with aria-expanded
Toggle buttonEnter or Spacearia-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>
  1. The input keeps the focus the whole time; the list is a popup it controls, not a separate focus target.
  2. aria-activedescendant points at the visually highlighted option so the user's typing is not interrupted.
  3. The visible list and the accessibility tree must agree. Updating one and not the other is the classic combobox bug.
  4. Announce the result count in a live region, politely. Without it, a screen reader user does not know the list changed.
  5. 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.

Focus management, ARIA and testing Live regions and dynamic content announcements

Last refreshed 2026-09-18.