Accessible disclosures, accordions and tabs

Build show-and-hide components and tab interfaces with the right markup, keyboard support and ARIA states.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Build an accordion with details or buttons
  • Build keyboard-operable tabs
  • Choose between a disclosure, an accordion and tabs

Disclosures, accordions and tabs all hide some content until the user asks for it. They look similar but behave differently, and each has an established pattern in the ARIA Authoring Practices Guide (APG). Users of assistive technology rely on those patterns being followed.

Which component to use

ComponentWhat it isKeyboardBuild with
DisclosureOne button that shows and hides one sectionTab to it, Enter or Space togglesdetails and summary, or a button with aria-expanded
AccordionA stack of disclosures with headings, often in an FAQSame as disclosure; arrow keys optionaldetails elements, or headings containing buttons
TabsA set of panels where exactly one is visibleTab into the tab list, arrow keys between tabsrole="tablist", tab and tabpanel

Prefer disclosures and accordions where you can. They are simpler, everyone understands them, and content can be expanded side by side. Use tabs when users compare a small number of views of the same thing and only one should show at a time.

Do not use the tabs pattern for site navigation. If each "tab" loads a different page or URL, it is navigation: use links in a nav, with aria-current="page" on the current one.

Disclosures with details and summary

The native details element needs no JavaScript and no ARIA. The summary is focusable, toggles with Enter and Space, and screen readers announce it as expanded or collapsed.

HTML
<details>
  <summary>How long can I borrow a book?</summary>
  <p>Three weeks. You can renew up to five times if nobody else has reserved it.</p>
</details>
CSS
summary {
  cursor: pointer;
  padding: 0.75rem 0;
  font-weight: 600;
}

summary:focus-visible {
  outline: 3px solid #1a5fb4;
  outline-offset: 2px;
}

Keep the summary text short and do not put headings, links or buttons inside it. Its content is announced as the button's name, and nested interactive elements cause problems.

An exclusive accordion

Give several details elements the same name and only one can be open at a time. This is supported in all current major browsers; older browsers simply allow several to be open.

HTML
<details name="faq">
  <summary>How long can I borrow a book?</summary>
  <p>Three weeks.</p>
</details>
<details name="faq">
  <summary>Can I return books to another branch?</summary>
  <p>Yes, to any library in the county.</p>
</details>

Exclusive accordions can frustrate people who want to compare answers, so use them only when it helps.

Accordions with headings and buttons

When each section needs a real heading in the page outline, or the design needs more control, use the APG accordion structure: a heading containing a button with aria-expanded.

HTML
<div class="accordion">
  <h3>
    <button type="button" id="acc-1-btn" aria-expanded="false" aria-controls="acc-1">
      Opening hours
    </button>
  </h3>
  <div id="acc-1" hidden>
    <p>Monday to Saturday, 9am to 6pm.</p>
  </div>

  <h3>
    <button type="button" id="acc-2-btn" aria-expanded="false" aria-controls="acc-2">
      Accessibility at the library
    </button>
  </h3>
  <div id="acc-2" hidden>
    <p>Step-free entrance, lift to all floors and a hearing loop at the main desk.</p>
  </div>
</div>

<script>
  document.querySelectorAll('.accordion [aria-expanded]').forEach((button) => {
    const panel = document.getElementById(button.getAttribute('aria-controls'));
    button.addEventListener('click', () => {
      const expanded = button.getAttribute('aria-expanded') === 'true';
      button.setAttribute('aria-expanded', String(!expanded));
      panel.hidden = expanded;
    });
  });
</script>
  • The button provides focus, keyboard support and the name.
  • The heading puts each section in the heading outline, so screen reader users can jump between sections with H.
  • aria-expanded tells users whether the section is open.
  • hidden removes the closed panel from the accessibility tree and the tab order.

Style the open and closed icon from the attribute, so it always matches the state:

CSS
.accordion button::after {
  content: "+";
  margin-inline-start: 0.5rem;
}

.accordion button[aria-expanded="true"]::after {
  content: "\2212"; /* minus sign */
}

The APG accordion pattern also allows optional arrow-key navigation between headers. It is not required, and many users expect only Tab.

Tabs

Tabs follow a more complex pattern, and users of screen readers expect it exactly.

  • The tab list has role="tablist" and a name.
  • Each tab is a button with role="tab", aria-selected and aria-controls.
  • Each panel has role="tabpanel" and is named by its tab with aria-labelledby.
  • Only the selected tab is in the tab order. The others have tabindex="-1". This is called a roving tabindex.
  • Arrow keys move between tabs. Home and End go to the first and last tab.
  • Tab from the selected tab moves into the panel.

The markup

HTML
<h2 id="hours-title">Opening times</h2>
<div class="tabs">
  <div role="tablist" aria-labelledby="hours-title">
    <button type="button" role="tab" id="tab-week" aria-selected="true" aria-controls="panel-week">Weekdays</button>
    <button type="button" role="tab" id="tab-weekend" aria-selected="false" aria-controls="panel-weekend" tabindex="-1">Weekends</button>
    <button type="button" role="tab" id="tab-holiday" aria-selected="false" aria-controls="panel-holiday" tabindex="-1">Holidays</button>
  </div>

  <div role="tabpanel" id="panel-week" aria-labelledby="tab-week" tabindex="0">
    <p>9am to 6pm.</p>
  </div>
  <div role="tabpanel" id="panel-weekend" aria-labelledby="tab-weekend" tabindex="0" hidden>
    <p>10am to 4pm.</p>
  </div>
  <div role="tabpanel" id="panel-holiday" aria-labelledby="tab-holiday" tabindex="0" hidden>
    <p>Closed on public holidays.</p>
  </div>
</div>

The panels have tabindex="0" because they contain no focusable elements. Without it, keyboard users pressing Tab from the tab list would skip the panel entirely. If a panel starts with a focusable element, you can leave tabindex off that panel.

The script

JavaScript
function initTabs(tablist) {
  const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));

  function selectTab(newTab) {
    for (const tab of tabs) {
      const selected = tab === newTab;
      tab.setAttribute('aria-selected', String(selected));
      tab.tabIndex = selected ? 0 : -1;
      document.getElementById(tab.getAttribute('aria-controls')).hidden = !selected;
    }
    newTab.focus();
  }

  tablist.addEventListener('click', (event) => {
    const tab = event.target.closest('[role="tab"]');
    if (tab) selectTab(tab);
  });

  tablist.addEventListener('keydown', (event) => {
    const index = tabs.indexOf(document.activeElement);
    if (index === -1) return;

    let next;
    switch (event.key) {
      case 'ArrowRight':
        next = tabs[(index + 1) % tabs.length];
        break;
      case 'ArrowLeft':
        next = tabs[(index - 1 + tabs.length) % tabs.length];
        break;
      case 'Home':
        next = tabs[0];
        break;
      case 'End':
        next = tabs[tabs.length - 1];
        break;
      default:
        return;
    }
    event.preventDefault();
    selectTab(next);
  });
}

document.querySelectorAll('[role="tablist"]').forEach(initTabs);

This uses automatic activation: moving to a tab with the arrow keys shows its panel straight away. The APG recommends it when panels display instantly. If showing a panel is slow, for example because it loads data, use manual activation: arrow keys move focus only, and Enter or Space selects. Arrow keys wrap from the last tab to the first, as the APG suggests.

For vertical tab lists, add aria-orientation="vertical" to the tab list and use ArrowUp and ArrowDown instead.

Styling the selected tab

The selected tab must be visibly different by more than colour (1.4.1 Use of Color), and the indicator needs 3:1 contrast (1.4.11 Non-text Contrast).

CSS
[role="tab"][aria-selected="true"] {
  border-bottom: 4px solid currentColor;
  font-weight: 700;
}

For progressive enhancement, render the panels as normal sections with headings in the HTML, and add the tab roles, hidden and tabindex from JavaScript. If the script fails, users still get all the content.

Testing checklist

  1. Can you reach and operate every disclosure button with Enter and Space?
  2. Does a screen reader announce "expanded" and "collapsed", and update after each press?
  3. In tabs, does Tab reach only the selected tab, then move into its panel?
  4. Do arrow keys, Home and End move between tabs and show the right panel?
  5. Does a screen reader announce "tab, selected, 1 of 3" or similar?
  6. Is hidden content really hidden from screen readers when closed?

Summary

  • Disclosures show and hide one section; accordions stack them; tabs show exactly one of several panels.
  • Use details and summary where they fit; they need no ARIA or JavaScript.
  • For custom accordions, put a button with aria-expanded inside each heading and toggle hidden on the panel.
  • Tabs need tablist, tab and tabpanel roles, aria-selected, a roving tabindex and arrow-key support.
  • Never use tabs for navigation between pages; use links.
  • Style selected and expanded states from the ARIA attributes, with more than colour.