All articles
Accessibility

How to Build Accessible Tabs, Accordions, and Menus (The ARIA Guide)

Stop building interactive UI with div onClick. Learn the exact ARIA roles, states, and keyboard patterns needed for accessible tabs, accordions, and menus.

Kuda Zafevere·Full-Stack Engineer
·15 min read
Share:

A developer ships a new accordion component. It looks sharp — smooth animation, clean typography, a subtle chevron that rotates on click. The design review passes. A keyboard user tabs into it, presses Enter, and nothing happens. The trigger is a <div onClick>. There is no aria-expanded. The panel is toggled with CSS class manipulation but no state change is announced. The component is visually complete and functionally inaccessible.

This happens because the failure is invisible to sighted mouse users. The visual UI works. The interaction model does not. Accessibility bugs in tabs, accordions, and menus almost always come from wrong semantics and wrong focus management, not from styling problems.

TL;DR

  • Accordions should use real <button> elements with aria-expanded and aria-controls.
  • Tabs require a tablist / tab / tabpanel relationship plus arrow-key navigation with roving tabindex.
  • Menus are not the same as site-navigation dropdowns.
  • Using role="menu" for normal website navigation is usually incorrect.
  • Accessible interactive UI depends on correct keyboard support, focus order, and state relationships — not just ARIA labels on divs.

Before building new interactive components, a CodeAva Website Audit can flag structural and technical issues — metadata gaps, crawl signal problems, and security header omissions — that compound with accessibility defects. Catching the basics before manual keyboard testing ensures you are fixing real interaction problems, not chasing configuration errors.

The first rule: use native elements before ARIA

Before reaching for ARIA attributes, confirm you are using the right HTML elements. A real <button> is focusable, responds to Enter and Space, and is announced as a button by screen readers — all without any additional attributes. A <div> with an onClick handler has none of these behaviors natively.

ARIA is designed to clarify widget structure and communicate state to assistive technology. It is not a replacement for the semantics that HTML already provides. The correct approach for interactive components is: semantic HTML first, then ARIA to express relationships and state that HTML cannot convey alone, then JavaScript for keyboard behavior and focus management.

This does not mean ARIA is bad or unnecessary. It means that a <button> plus aria-expanded is correct, and a <div role="button" tabIndex={0}> with a manual onKeyDown handler is doing extra work for a worse result. Start with the right element. Add ARIA to describe what native HTML cannot.

Pattern 1: accordion / disclosure

An accordion is a set of vertically stacked disclosure widgets. Each consists of a trigger that toggles the visibility of an associated panel. This is one of the simpler accessible patterns, but it is frequently implemented incorrectly.

Correct structure

  • The trigger is a real <button> element.
  • The button carries aria-expanded="true" or aria-expanded="false" to reflect state.
  • The button carries aria-controls pointing to the id of the associated panel.
  • The button sits inside a heading element (<h3>, <h4>, etc.) appropriate for the page hierarchy.
  • The panel may optionally use role="region" with aria-labelledby pointing back to the trigger when the panel content is substantial enough to warrant landmark navigation. Do not apply role="region" to every panel — excessive landmarks reduce their utility.

Required attributes

ElementAttributePurpose
button(native element)Trigger — focusable, keyboard-operable by default
buttonaria-expandedCommunicates open/closed state
buttonaria-controlsPoints to the panel id
panelidTarget for aria-controls
panelrole="region"Optional — adds landmark navigation for substantial content
panelaria-labelledbyOptional — pairs with role="region", points to trigger

Keyboard behavior

  • Enter / Space — toggles the associated panel
  • Tab / Shift+Tab — moves through the page in normal tab order
  • Arrow keys, Home, End — optional: may move focus between accordion headers if the widget implements grouped keyboard navigation

Implementation example

function AccordionItem({ id, title, children }) {
  const [open, setOpen] = useState(false);
  const panelId = `panel-${id}`;
  const triggerId = `trigger-${id}`;

  return (
    <div>
      <h3>
        <button
          id={triggerId}
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen(!open)}
          className="flex w-full items-center justify-between py-3 text-left font-medium"
        >
          {title}
          <ChevronIcon className={open ? "rotate-180" : ""} />
        </button>
      </h3>
      <div
        id={panelId}
        role="region"
        aria-labelledby={triggerId}
        hidden={!open}
      >
        {children}
      </div>
    </div>
  );
}

The hidden attribute ensures the panel is not reachable by Tab or announced by assistive technologies when collapsed. CSS-only visibility toggling (display: none or visibility: hidden) also works if the element is truly removed from the accessibility tree. Avoid toggling opacity or height alone — both can leave hidden content focusable.

Pattern 2: tabs

Tabs are more structurally complex than accordions. They require a three-part relationship — tablist, tab, and tabpanel — with roving tabindex and arrow-key navigation. Getting tabs wrong is one of the most common sources of accessible interactive component failures.

Correct structure

  • A container with role="tablist" holds all tab triggers.
  • Each trigger has role="tab".
  • Each content panel has role="tabpanel".
  • The active tab carries aria-selected="true". Inactive tabs carry aria-selected="false".
  • Each tab points to its panel with aria-controls.
  • Each panel points back to its tab with aria-labelledby.

Required attributes

ElementAttributePurpose
Containerrole="tablist"Groups the tab triggers
Triggerrole="tab"Identifies each trigger as a tab
Triggeraria-selectedIndicates active/inactive state
Triggeraria-controlsPoints to the panel id
TriggertabindexActive tab: 0, inactive: -1
Panelrole="tabpanel"Identifies the content panel
Panelaria-labelledbyPoints back to its tab trigger
Tablistaria-orientationOptional — set to "vertical" for vertical tab layouts

Keyboard and focus behavior

  • Tab — moves focus into the tablist and lands on the currently active tab (the one with tabindex="0").
  • Left / Right arrow — moves between tabs (or Up / Down for vertical tablists). Focus wraps from last to first and vice versa.
  • Space / Enter — activates the focused tab in manual-activation mode. In automatic-activation mode, tabs activate on arrow focus.
  • Tab (again) — moves focus out of the tablist to the active panel or the next focusable element in the page. Focus does not cycle through all tabs.
  • Home / End — optional: moves focus to the first or last tab.

This is roving tabindex: only the active tab has tabindex="0". All inactive tabs have tabindex="-1". When the active tab changes, update tabindex, aria-selected, and move focus programmatically with .focus().

Automatic activation — where simply arrowing to a tab switches the panel — is acceptable when panel content loads instantly. If switching tabs triggers a network request or heavy rendering, use manual activation (require Enter or Space) so keyboard users are not forced through expensive panel loads while navigating between tabs.

If the active tabpanel has no naturally focusable content inside it, add tabindex="0" to the panel itself so keyboard users can reach its content after pressing Tab from the tablist.

Implementation example

function Tabs({ tabs }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef([]);

  function handleKeyDown(e) {
    let next = activeIndex;
    if (e.key === "ArrowRight") next = (activeIndex + 1) % tabs.length;
    if (e.key === "ArrowLeft")  next = (activeIndex - 1 + tabs.length) % tabs.length;
    if (e.key === "Home")       next = 0;
    if (e.key === "End")        next = tabs.length - 1;

    if (next !== activeIndex) {
      e.preventDefault();
      setActiveIndex(next);
      tabRefs.current[next]?.focus();
    }
  }

  return (
    <div>
      <div role="tablist" aria-label="Example tabs">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            id={`tab-${tab.id}`}
            ref={(el) => (tabRefs.current[i] = el)}
            role="tab"
            aria-selected={i === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={i === activeIndex ? 0 : -1}
            onKeyDown={handleKeyDown}
            onClick={() => setActiveIndex(i)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={tab.id}
          id={`panel-${tab.id}`}
          role="tabpanel"
          aria-labelledby={`tab-${tab.id}`}
          hidden={i !== activeIndex}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Common wrong approach

{/* ❌ Every tab is tabbable. No roving tabindex. No arrow keys. No ARIA roles. */}
<div className="tabs">
  {tabs.map((tab) => (
    <div
      key={tab.id}
      className={tab.active ? "tab active" : "tab"}
      onClick={() => setActive(tab.id)}
      tabIndex={0}
    >
      {tab.label}
    </div>
  ))}
</div>
<div className="panel">{activeTab.content}</div>

This version uses divs instead of buttons, makes every tab tabbable (forcing keyboard users to step through each one), has no ARIA roles or relationships, and no arrow-key navigation. It looks like tabs but does not behave like them for anyone not using a mouse.

Pattern 3: menus vs navigation

This is where the most consequential mistake happens. Not every dropdown is a menu. A website header with links to other pages is navigation. An application toolbar with actions like Cut, Copy, and Paste is a menu. The ARIA pattern, the keyboard model, and the focus behavior are different.

A) Navigation dropdowns (disclosure pattern)

A site header dropdown that reveals a list of links when clicked is a disclosure widget — a button that toggles visibility. The correct implementation uses:

  • A <nav> container for semantic navigation.
  • A real <button> trigger with aria-expanded and aria-controls.
  • A list of <a> links inside the toggled container.
  • Normal tab order — Tab and Shift+Tab move through the visible links.

Required attributes

  • <button> — trigger element
  • aria-expanded — reflects visibility state
  • aria-controls — points to the dropdown container id
function NavDropdown({ label, links }) {
  const [open, setOpen] = useState(false);
  const menuId = `nav-${label.toLowerCase().replace(/\s+/g, "-")}`;

  return (
    <div className="relative">
      <button
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen(!open)}
      >
        {label}
      </button>
      {open && (
        <ul id={menuId} className="absolute top-full left-0">
          {links.map((link) => (
            <li key={link.href}>
              <a href={link.href}>{link.text}</a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Notice there is no role="menu" and no role="menuitem". The links are regular anchor elements inside a list. Keyboard users navigate them with Tab and Shift+Tab, which is the expected behavior for navigational links. Optional arrow-key enhancements can supplement this, but should not replace normal tabbing.

B) Application menus (menu button pattern)

A true ARIA menu is an application-style command widget. It is appropriate for: right-click context menus, toolbar action dropdowns, or editor command palettes — not for lists of navigation links.

  • The trigger is a <button> with aria-haspopup="menu" and aria-expanded.
  • The popup container has role="menu".
  • Items use role="menuitem", role="menuitemcheckbox", or role="menuitemradio" as appropriate.
  • When the menu opens, focus moves into the menu. Arrow keys navigate between items.
  • Escape closes the menu and returns focus to the trigger button.

Required attributes

ElementAttributePurpose
TriggerbuttonNative element
Triggeraria-haspopup="menu"Announces a menu will open
Triggeraria-expandedReflects open/closed state
Triggeraria-controlsOptional — points to menu id
Popuprole="menu"Identifies the container as a menu widget
Itemsrole="menuitem"Identifies each command as a menu item
function ActionMenu({ label, actions }) {
  const [open, setOpen] = useState(false);
  const menuRef = useRef(null);
  const triggerRef = useRef(null);
  const menuId = "action-menu";

  useEffect(() => {
    if (open) menuRef.current?.querySelector('[role="menuitem"]')?.focus();
  }, [open]);

  function handleMenuKeyDown(e) {
    const items = [...menuRef.current.querySelectorAll('[role="menuitem"]')];
    const idx = items.indexOf(document.activeElement);

    if (e.key === "ArrowDown") {
      e.preventDefault();
      items[(idx + 1) % items.length]?.focus();
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      items[(idx - 1 + items.length) % items.length]?.focus();
    } else if (e.key === "Escape") {
      setOpen(false);
      triggerRef.current?.focus();
    }
  }

  return (
    <div className="relative">
      <button
        ref={triggerRef}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen(!open)}
      >
        {label}
      </button>
      {open && (
        <div
          id={menuId}
          ref={menuRef}
          role="menu"
          onKeyDown={handleMenuKeyDown}
        >
          {actions.map((action) => (
            <button
              key={action.id}
              role="menuitem"
              tabIndex={-1}
              onClick={() => { action.handler(); setOpen(false); }}
            >
              {action.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Do not use role="menu" for site navigation

If your dropdown contains links to other pages — like a header nav with Products, Pricing, and Docs links — it is navigation disclosure, not a menu widget. Using role="menu" and role="menuitem" on navigation links changes the expected keyboard model and confuses assistive technology users who expect arrow-key focus management inside a menu but normal tabbing inside navigation.

Keyboard map: exact keys developers must support

Accordion

  • Tab / Shift+Tab — normal page tab order through each accordion header
  • Enter / Space — toggle the associated panel
  • Down Arrow / Up Arrow — optional: move focus between accordion headers
  • Home / End — optional: move focus to the first / last header

Tabs

  • Tab — enter the tablist (land on the active tab)
  • Left / Right Arrow — move between tabs (wraps)
  • Up / Down Arrow — move between tabs in vertical tablists
  • Space / Enter — activate tab (manual activation mode)
  • Home / End — optional: first / last tab
  • Tab (again) — leave the tablist, focus moves to active panel or next focusable element

Menu button / menu

  • Enter / Space — open the menu (on trigger)
  • Down Arrow — open the menu and focus the first item (from trigger), or move to next item (inside menu)
  • Up Arrow — move to previous item (inside menu), or focus the last item when opening
  • Escape — close the menu and return focus to the trigger
  • Home / End — optional: first / last item in menu

Focus management: where most implementations fail

A component can pass a visual design review, render the right ARIA attributes in the DOM, and still be inaccessible because focus does not behave correctly. These are the failures that only surface during keyboard or screen reader testing.

  • Focus lost when a panel opens. When a disclosure panel appears, focus should remain on the trigger (for accordions) or move predictably into the content (for menus). If focus is not explicitly managed, it can land on the document body or an unpredictable element.
  • Focus trapped incorrectly. Modal dialogs need focus trapping. Accordions and tab panels do not. Trapping focus in a non-modal widget prevents keyboard users from reaching other page content.
  • Every tab tabbable. Without roving tabindex, keyboard users step through every tab trigger individually before reaching content. This is the most common tabs implementation failure.
  • Hidden content still focusable. Collapsed panels, inactive tabpanels, and closed menus must be actually removed from the tab order. Using hidden, display: none, or visibility: hidden achieves this. Toggling only opacity, max-height, or a CSS class that does not remove the element from the accessibility tree does not.
  • Menu closes without returning focus. When a menu closes via Escape or item selection, focus must return to the trigger button. If focus goes nowhere, keyboard users lose their place on the page entirely.
  • Focus outlines removed with no replacement. Removing the default outline is common for visual design reasons, but without a visible replacement focus indicator, keyboard users cannot tell where they are on the page.
  • ARIA state out of sync. When visual state changes — a panel opens, a tab activates — but aria-expanded, aria-selected, or tabindex are not updated to match, the visual and accessible states diverge. Screen reader users see stale information.

Invisible breakage

A component can look perfect and still fail accessibility if focus order, keyboard behavior, or ARIA state updates do not match the pattern users expect. The failure is invisible to sighted mouse users, which is why keyboard testing must be part of the development workflow, not a post-launch afterthought.

Common anti-patterns

  • div onClick as a button. Missing keyboard operability, missing role, not focusable by default. Use a real <button>.
  • Tabs controlled only by mouse clicks. No arrow-key navigation, no roving tabindex, every tab tabbable. This is not a tab widget — it is a styled list of buttons.
  • role="menu" for a basic nav dropdown. Changes the expected keyboard model from Tab navigation to arrow-key navigation. Users of assistive technology expect different interaction patterns inside a menu widget than inside a navigation list.
  • Focusable elements hidden visually but still reachable. If you hide content by toggling a class that only adjusts opacity or position, the elements remain in the tab order and can be focused invisibly.
  • Removing outline with no accessible replacement. Without a visible focus indicator, keyboard navigation becomes guesswork. Use :focus-visible for a custom indicator that appears only for keyboard users if needed.
  • ARIA roles that conflict with native semantics. Adding role="button" to an <a> tag, or role="tab" to a <div> without providing the corresponding keyboard behavior, creates misleading announcements without the matching interactivity.
  • Copying a component library without understanding the keyboard model. Component libraries often implement correct ARIA patterns internally. When developers rewrite or simplify these components, the ARIA attributes and keyboard behavior are usually the first things dropped.

Testing the experience with no mouse

  1. Unplug the mouse or stop using it entirely. Place your hands on the keyboard and navigate using only Tab, Shift+Tab, Enter, Space, arrow keys, and Escape.
  2. Tab through the component. Confirm that focus lands on every interactive element in a logical order. Look for visible focus indicators at every step.
  3. Verify visible focus at every step. If you cannot tell where focus is, the component fails the test immediately regardless of its ARIA markup.
  4. Use the required keys for the widget pattern. Enter and Space for accordion toggles. Arrow keys for tab switching. Escape for menu closing. Confirm that each key does what the pattern specifies.
  5. Confirm that state changes update ARIA attributes. When a panel opens, aria-expanded should flip. When a tab activates, aria-selected should update. Inspect the DOM live during interaction if needed.
  6. Confirm hidden content is not accidentally focusable. With the panel or menu closed, Tab past it. If focus disappears into invisible content, the hidden element is still in the tab order.
  7. Confirm closing returns focus logically. Closing a menu with Escape should return focus to the trigger button. Closing an accordion panel should leave focus on the trigger. Focus should never land on the document body or an unexpected element.
  8. Run a screen-reader smoke test if possible. VoiceOver on macOS, NVDA on Windows, or TalkBack on Android. Listen for role announcements, expanded/collapsed state, and selected-tab state. Confirm they match the visual state.

Component pattern comparison

PatternCorrect semantic baseRequired ARIA / statePrimary keyboard modelMost common mistake
Accordionbutton in heading + panelaria-expanded, aria-controlsTab, Enter/Spacediv onClick instead of button
Tabstablist + tab + tabpanelaria-selected, aria-controls, aria-labelledby, roving tabindexArrow keys between tabs, Tab in/outEvery tab tabbable, no arrow keys
Nav disclosurenav + button + list of linksaria-expanded, aria-controlsTab through links normallyUsing role="menu"
Menu buttonbutton + role="menu" + menuitemaria-haspopup, aria-expanded, focus managementArrows inside menu, Escape to closeUsing for page navigation links

Catching invisible failures before launch

Keyboard and focus issues are invisible in visual design reviews. A CodeAva Website Audit covers foundational technical signals — metadata, HTTPS, crawl signals, and security headers — and is a useful pre-launch check to confirm the structural basics before manual accessibility testing begins. The audit does not inspect DOM-level ARIA roles or focus behavior, but catching missing metadata, broken canonical tags, or absent security headers ensures your accessibility review is focused on interaction quality rather than configuration errors.

For component-level accessibility verification, keyboard testing (the checklist above) and screen-reader smoke tests remain the most reliable methods. Automated tools can flag missing labels and role conflicts, but only manual interaction testing confirms that focus order, keyboard behavior, and state updates match the ARIA pattern your component claims to implement.

Conclusion and next steps

Accessible interactive components are not built by sprinkling ARIA attributes on divs. They are built from the right semantic base, the right ARIA state relationships, and the right keyboard model for the pattern. Accordions, tabs, and menus are not interchangeable — each has distinct structure, focus management, and keyboard expectations.

Most accessibility bugs in interactive UI come from focus mismanagement and state mismatches: focus that lands in the wrong place, hidden content that is still reachable, ARIA attributes that do not update with the visual state, and keyboard behavior that does not match the widget pattern. These issues are invisible to sighted mouse users and will not surface in visual design reviews.

The fix is to test with a keyboard before calling a component finished. Tab through it. Operate it with the keys the pattern requires. Confirm that focus, state, and announcements all match. If they do not, the component is not done — regardless of how it looks.

Start with the basics

Run a CodeAva Website Audit to catch structural and technical issues before manual accessibility testing. Then follow the keyboard testing checklist above for every interactive component before launch. For related patterns on form accessibility, see the guide on form validation accessibility and ARIA live regions.

#accessibility#wai-aria#keyboard-navigation#focus-management#semantic-html#wcag#interactive-components#tabs#accordion#menu

Frequently asked questions

More from Kuda Zafevere

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.