Codeskill

Learn to code, step by step

Accessible rich widgets – tabs, dialogs, menus (HTML+ARIA)

Accessible rich widgets built with HTML and ARIA: tabs, dialogs, and menus. These patterns need JavaScript for behaviour, but the markup contract comes first. Get the roles, labels, and keyboard expectations right in HTML, then wire up the script to match.

Tabs

A tab interface has a tab list, tab buttons, and tab panels. One panel is visible at a time. Arrow keys move between tabs; Home and End jump to first and last. Follow the APG tab pattern:

<div class="tabs">
  <div role="tablist" aria-label="Account sections">
    <button type="button" role="tab" id="tab-profile"
            aria-selected="true" aria-controls="panel-profile">
      Profile
    </button>
    <button type="button" role="tab" id="tab-security"
            aria-selected="false" aria-controls="panel-security" tabindex="-1">
      Security
    </button>
  </div>

  <div role="tabpanel" id="panel-profile"
       aria-labelledby="tab-profile" tabindex="0">
    <!-- profile content -->
  </div>

  <div role="tabpanel" id="panel-security"
       aria-labelledby="tab-security" tabindex="0" hidden>
    <!-- security content -->
  </div>
</div>

Only the active tab sits in the tab order (tabindex="0"); inactive tabs use tabindex="-1" but remain focusable via arrow keys once the tab list has focus. Hide inactive panels with the hidden attribute or equivalent, and toggle aria-selected in script. For a progressive baseline, real links to separate sections still work without JavaScript.

Dialogs

Native <dialog> is the first choice in supporting browsers. It gives you modal behaviour, focus management hooks, and a clear element to style:

<button type="button" id="open-delete-dialog">Delete account</button>

<dialog id="delete-dialog" aria-labelledby="delete-title">
  <h2 id="delete-title">Delete your account?</h2>
  <p>This cannot be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>

Call dialog.showModal() from JavaScript to open modally. The browser traps focus and blocks interaction with the page behind. Always provide a visible title referenced by aria-labelledby. For custom dialog markup on older targets, use role="dialog", aria-modal="true", focus trap logic, and Escape to close – but prefer <dialog> when you can.

Menu button and dropdown

A menu button opens a list of actions. This is not the same as site navigation – use role="menu" only for action menus, not for a list of links to other pages:

<div class="menu">
  <button type="button" id="file-menu-button"
          aria-haspopup="menu" aria-expanded="false"
          aria-controls="file-menu">
    File
  </button>

  <ul role="menu" id="file-menu" aria-labelledby="file-menu-button" hidden>
    <li role="none">
      <button type="button" role="menuitem">New</button>
    </li>
    <li role="none">
      <button type="button" role="menuitem">Open…</button>
    </li>
    <li role="none">
      <button type="button" role="menuitem">Save</button>
    </li>
  </ul>
</div>

Toggle aria-expanded when open. Arrow keys move between menuitem entries; Escape closes and returns focus to the button. Click outside should close the menu. If the dropdown is just navigation links, skip the menu roles and use a plain list inside a disclosed region with aria-expanded on the button.

Shared checklist

  • Focus visible on every interactive element
  • Keyboard path matches mouse path (no mouse-only controls)
  • State reflected in HTML attributes, not only CSS classes
  • Works without JavaScript where possible (links, <details>, native <dialog>)
  • Test with keyboard and at least one screen reader

Rich widgets are where teams most often break accessibility. Copy vetted patterns, keep markup stable, and treat JavaScript as the implementation detail that honours the contract you defined in HTML.

PreviousAccessibility deep dive – ARIA patterns that are actually needed