Accessible Accordions and Tabs: ARIA + Keyboard Patterns

Accordions and tabs look similar but need different ARIA, different keyboard handling, and serve different purposes. Here is how to build each one correctly without breaking screen readers.


Accordions and tabs are the two most over-used components on the modern web. They look almost identical when you squint, they often share the same JavaScript library, and developers tend to grab whichever one their design system calls "collapsible." From an accessibility standpoint, that interchangeability is a disaster. Accordions and tabs are not the same pattern. They have different ARIA roles, different keyboard contracts, and different reasons to exist. Building one with the rules for the other is one of the fastest ways to ship a component that works fine for sighted mouse users and is completely broken for everyone else.

This post walks through both patterns side by side: when each one is appropriate, the WCAG criteria they touch, the exact markup that makes them accessible, and the bugs we find most often when we scan customer sites. If you maintain a component library or you are about to reach for an accordion, read both halves before you start typing.

Accordions vs tabs: when to use which

Before we get into ARIA, you have to know which pattern you are actually building. The decision is structural, not visual.

An accordion is a vertical stack of headings. Each heading expands or collapses an associated panel of content directly underneath it. Multiple panels can typically be open at once. The user reads top to bottom: heading, optional content, next heading, optional content. Think FAQs, settings groups, or a sidebar of collapsible filters.

A tabset is a horizontal (or sometimes vertical) row of labels with a single content area beneath them. Only one panel is visible at a time. The user picks a label and the content area swaps. Think product detail pages with Description, Specs, and Reviews tabs, or settings pages with Profile, Security, and Billing tabs.

The user goal is different in each case. Accordions exist to compress a long page so users can scan headings and expand only what they care about. Tabs exist to slice peer content into mutually exclusive views. If you cannot describe your component in one of those two sentences, you probably have the wrong pattern.

Why does this matter for accessibility? Because screen reader users navigate accordions by heading, and they navigate tabs by tab. The keyboard model is different too. Accordions use Tab and Enter. Tabs use Arrow keys. Mix them up and you break the mental model that assistive technology users rely on.

The WCAG criteria that apply

Both patterns touch the same core success criteria, but they fail them in different ways.

4.1.2 Name, Role, Value (Level A). Every interactive element needs a programmatic role and an accessible name. An accordion header that is just a styled div with an onclick handler has no role and no name. A tab that is rendered as a list item with no role="tab" tells screen readers nothing about its purpose.

2.1.1 Keyboard (Level A). Every action that works with a mouse must work with a keyboard. Clicking an accordion header to expand it must be reachable with Tab and triggerable with Enter or Space. Clicking a tab must be reachable with Tab and switchable with the arrow keys.

2.4.3 Focus Order (Level A). The tab order through an accordion or tabset must match the visual order. A common bug is hiding collapsed accordion panels with display: none on the panel but leaving its focusable children in the tab order via JavaScript that forgot to update tabindex.

2.4.7 Focus Visible (Level AA). Tab labels and accordion headers must show a visible focus indicator. Removing the default outline for cosmetic reasons without replacing it is the single most common keyboard accessibility failure on the web.

1.3.1 Info and Relationships (Level A). The relationship between a control and the panel it controls must be programmatic. For accordions, that is aria-controls and aria-expanded. For tabs, that is aria-controls, aria-selected, and role="tabpanel".

Building an accessible accordion

The simplest accordion: native <details> and <summary>

Before reaching for any JavaScript, ask whether the native <details> and <summary> elements are enough. They are an HTML disclosure widget, built into every modern browser, and they implement the accordion contract for you with zero script.

<details>
  <summary>When will my order ship?</summary>
  <p>Orders placed before 2pm ship the same business day.</p>
</details>

That is the entire component. The browser handles everything you would otherwise have to build by hand. The summary is focusable. Enter and Space toggle it. Screen readers announce it as "When will my order ship, summary, collapsed" and update the state when it opens. The disclosed content is added to and removed from the accessibility tree automatically. There is nothing to wire up.

For a stack of FAQs, simply use one <details> per item, optionally wrapped in a heading so screen reader users can still jump between sections by heading navigation:

<h3>
  <details>
    <summary>When will my order ship?</summary>
    <p>Orders placed before 2pm ship the same business day.</p>
  </details>
</h3>

If you want the "exclusive accordion" behavior where opening one item closes the others, group your <details> elements with the same name attribute (supported in all major browsers as of 2024):

<details name="faq">
  <summary>When will my order ship?</summary>
  <p>Orders placed before 2pm ship the same business day.</p>
</details>
<details name="faq">
  <summary>What is your return policy?</summary>
  <p>Returns are accepted within 30 days of delivery.</p>
</details>

The native pattern is the right default for FAQs, documentation, content disclosures, and most "click a heading, see more text" use cases. Use it whenever you can.

That said, <details> is not a perfect fit for every accordion. There are three real reasons to fall back to the manual button-plus-heading pattern shown below:

Custom open/close animations. The browser opens and closes <details> instantly. CSS transitions on height: auto and the ::details-content pseudo-element are improving rapidly, but if you need a fully custom animation that runs reliably across browsers today, you may need a JavaScript implementation.

Heavy custom styling of the disclosure marker. The default triangle marker can be hidden with summary::-webkit-details-marker { display: none; } and summary { list-style: none; }, but if your design calls for the marker on the right side, with custom rotation animations and so on, you have to either restyle these pseudo-elements carefully or build your own.

You need to programmatically link the trigger to a panel that is not its direct child. The <details> element requires the disclosed content to live inside it. If your design system needs the trigger and the panel in different parts of the DOM (rare, but it happens for grid-based layouts), you cannot use <details> and you need aria-controls.

If none of those apply, stop here. Use <details> and move on.

Building an accordion when <details> is not enough

The accordion pattern is simple enough that you do not need a library. The trick is using the right elements: a real <button> wrapped inside a real heading. The heading is what gives screen reader users the ability to jump between sections. The button is what gives keyboard users the ability to operate it.

Here is the minimal accessible accordion item:

<h3>
  <button type="button"
          aria-expanded="false"
          aria-controls="panel-shipping"
          id="trigger-shipping">
    When will my order ship?
  </button>
</h3>
<div id="panel-shipping"
     role="region"
     aria-labelledby="trigger-shipping"
     hidden>
  <p>Orders placed before 2pm ship the same business day.</p>
</div>

That is the entire structural pattern. A few things to notice.

The heading level (h3 here) should match the document outline. If the accordion lives inside a section already headed by an h2, use h3 for each item. If it lives at the top of a page section, an h2 may be appropriate. Do not use a heading you would not have used otherwise just because the pattern needs one. Skipping levels is its own WCAG violation under 1.3.1.

The aria-expanded attribute on the button is what screen readers announce: "When will my order ship, button, collapsed." When the panel opens, JavaScript updates it to aria-expanded="true" and the announcement becomes "expanded." This is the entire state model. You do not need aria-pressed, you do not need role="button" (the element is already a button), and you do not need a separate live region.

The panel uses role="region" and aria-labelledby pointing back to the trigger button. This makes the panel show up as a labelled landmark so screen reader users can navigate to it directly. The hidden attribute (rather than display: none in CSS) is preferred because it is a single source of truth: the element is hidden from layout and from assistive technology in one declaration. Toggle it by removing the attribute, not by adding a class.

The JavaScript is just three lines:

trigger.addEventListener('click', () => {
  const expanded = trigger.getAttribute('aria-expanded') === 'true';
  trigger.setAttribute('aria-expanded', String(!expanded));
  panel.hidden = expanded;
});

No focus management is needed. The user clicked the button, so the button still has focus. The panel content is below it in the DOM and they can Tab into it normally.

Accordion keyboard behavior

Because each accordion header is a real button inside a real heading, the keyboard model comes for free. Tab moves between headers (and into expanded panels). Enter or Space activates the focused header. Shift+Tab goes back. That is the whole contract.

Some component libraries add Up and Down arrow shortcuts for moving between accordion headers. The W3C ARIA Authoring Practices Guide lists these as optional. They are nice to have but they are not required, and if you add them you must not break the default Tab behavior. We have seen libraries that disable Tab navigation in favor of arrows, which immediately fails 2.1.1 because users who do not know about the arrows are stuck.

Common accordion mistakes

The four bugs we find most often:

Using a div instead of a button. A clickable div is not focusable, has no implicit role, and does not respond to Enter or Space. Adding role="button" and tabindex="0" patches some of this but you still have to manually wire up Enter and Space to activate it. Just use a button.

Forgetting aria-expanded. Without it, the screen reader has no way to convey state. The user hears "When will my order ship, button" and has to click to find out what happens. With it, they hear "collapsed" or "expanded" before they commit.

Putting the heading inside the button instead of the button inside the heading. A <button><h3>...</h3></button> is not invalid HTML, but it removes the heading from the document outline because most screen readers do not announce headings nested inside interactive elements. Always wrap the button with the heading, not the other way around.

Hiding the panel with CSS but leaving its content focusable. If you collapse a panel with display: none, the content inside is removed from the tab order automatically. If you collapse it with visibility: hidden, same. But if you collapse it with height: 0; overflow: hidden; for an animation, the inner buttons and links are still in the tab order, invisible. Either use the hidden attribute or add inert to the panel while collapsed.

Building an accessible tabset

Tabs are a more complex pattern because the W3C ARIA spec defines a specific keyboard model that does not match anything else on the web. Tabs use Arrow keys to move between options, not Tab. This is intentional: Tab is reserved for moving out of the tabset entirely, into whatever panel is currently selected.

Here is the minimal accessible tabset:

<div class="tabs">
  <div role="tablist" aria-label="Account settings">
    <button role="tab"
            aria-selected="true"
            aria-controls="panel-profile"
            id="tab-profile"
            tabindex="0">Profile</button>
    <button role="tab"
            aria-selected="false"
            aria-controls="panel-security"
            id="tab-security"
            tabindex="-1">Security</button>
    <button role="tab"
            aria-selected="false"
            aria-controls="panel-billing"
            id="tab-billing"
            tabindex="-1">Billing</button>
  </div>
  <div role="tabpanel"
       id="panel-profile"
       aria-labelledby="tab-profile">
    <!-- profile content -->
  </div>
  <div role="tabpanel"
       id="panel-security"
       aria-labelledby="tab-security"
       hidden>
    <!-- security content -->
  </div>
  <div role="tabpanel"
       id="panel-billing"
       aria-labelledby="tab-billing"
       hidden>
    <!-- billing content -->
  </div>
</div>

A few things to call out.

The container has role="tablist" and an aria-label. The label is required if there is no visible heading above the tablist; without it, screen readers announce "tab list" with no context. If you do have a visible heading, use aria-labelledby pointing to it instead.

Each tab is a real <button> with role="tab". The role is necessary because tab is not the implicit role of any HTML element. aria-selected reflects which tab is active. aria-controls points to the panel that this tab opens.

The tabindex values implement what is called the "roving tabindex." Only the currently selected tab has tabindex="0", meaning only one tab is in the page tab order. The others have tabindex="-1", meaning they are focusable programmatically but skipped by Tab. When the user presses Tab from somewhere above, focus lands on the selected tab. When they press Tab again, focus moves out of the tablist entirely, into the active panel. This is the entire reason tabs feel different to keyboard users than every other component.

Tab keyboard behavior

Inside the tablist, the keyboard contract is:

Right Arrow: Move focus to the next tab. If on the last tab, optionally wrap to the first.
Left Arrow: Move focus to the previous tab. If on the first tab, optionally wrap to the last.
Home: Move focus to the first tab.
End: Move focus to the last tab.
Tab: Move focus out of the tablist into the active panel.

There is one design decision: should arrow keys also activate the new tab (showing its panel), or should they only move focus, with the user pressing Enter or Space to activate? The ARIA Authoring Practices Guide calls these "automatic activation" and "manual activation."

Automatic activation is the better default for most tabsets, because it matches the visual feedback users expect (arrow over, see the panel change). Use manual activation when activating a tab is expensive (loading data, running an animation) and you do not want it to happen for every keystroke as a user arrows past intermediate tabs.

Here is the activation logic, automatic version:

tablist.addEventListener('keydown', (event) => {
  const tabs = [...tablist.querySelectorAll('[role="tab"]')];
  const currentIndex = tabs.indexOf(document.activeElement);
  if (currentIndex === -1) return;

  let newIndex = currentIndex;
  if (event.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length;
  else if (event.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
  else if (event.key === 'Home') newIndex = 0;
  else if (event.key === 'End') newIndex = tabs.length - 1;
  else return;

  event.preventDefault();
  selectTab(tabs[newIndex]);
});

function selectTab(tab) {
  const tablist = tab.closest('[role="tablist"]');
  tablist.querySelectorAll('[role="tab"]').forEach((t) => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    document.getElementById(t.getAttribute('aria-controls')).hidden = true;
  });
  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
  tab.focus();
}

Common tab mistakes

Using anchors instead of buttons. Many tab implementations use <a href="#panel-id"> for tabs because it gives free deep linking. This is acceptable but only if the anchors also have role="tab", which overrides their implicit link role. If you skip the role override, screen reader users hear "link" instead of "tab" and lose all the tab semantics.

Putting every tab in the tab order. If you give every tab tabindex="0", the user has to Tab through all of them to reach the panel content. The roving tabindex pattern exists specifically to prevent this. Set tabindex="0" on the active tab only, tabindex="-1" on the rest.

Forgetting aria-selected. Without it, the screen reader cannot tell which tab is active. Some libraries use aria-current instead; that is wrong. aria-current is for indicating the current page in a navigation menu, not the selected option in a widget. Use aria-selected for tabs.

Conflating tabs and navigation. If clicking a "tab" loads a new page, it is not a tab, it is a link. Use <nav> with a list of links and skip the entire tab pattern. The role="tab" contract is for in-page widgets only.

Hiding inactive panels with CSS only. Same trap as accordions. If the inactive panels are hidden with display: none, their focusable contents are removed from the tab order. If they are hidden with opacity: 0 or height: 0, the contents are still focusable and Tab will find them. Use the hidden attribute.

Why accordions and tabs are not interchangeable

It is tempting to build an accordion and call its expanded items "tabs" or to build a tabset and call its panels "accordion sections." Component library authors do this all the time. Here is why it matters.

A screen reader user navigating an accordion uses heading navigation. They press H (NVDA) or use the rotor (VoiceOver) to jump from heading to heading, then Enter to expand the one they want. If you replace the headings with tabs, that workflow is broken. They have to land in the tablist first, then arrow through.

A screen reader user navigating a tabset hears the tablist announced as "Account settings, tab list, three tabs." They know exactly how many options there are and what they are looking at. If you instead build it as an accordion, they hear three separate buttons and have to figure out from context that these are mutually exclusive.

Mobile is the one place where the patterns sometimes converge: a horizontal tabset that does not fit on a narrow screen often collapses into an accordion. This is fine, as long as you swap the markup along with the layout, not just the CSS. The semantic structure has to match what is rendered. Two media queries on display do not change roles or keyboard behavior, and a tabset rendered as an accordion still tells screen readers "this is a tablist, use arrow keys" even though the user is looking at a vertical stack of headings.

Testing your accordions and tabs

Both patterns can be tested in under five minutes with just a keyboard. Here is the exact procedure.

Accordion test. Tab to the first accordion header. Confirm the focus indicator is visible. Press Enter and confirm the panel expands. Tab again and confirm focus moves into the panel content (not to the next header, skipping the panel). Shift+Tab back to the header. Press Enter again and confirm the panel collapses. Run a screen reader and confirm each header announces its expanded or collapsed state.

Tab test. Tab into the tablist. Focus should land on the selected tab. Press Right Arrow and confirm focus moves to the next tab and (for automatic activation) the corresponding panel becomes visible. Press Tab once and confirm focus moves out of the tablist into the active panel. Shift+Tab back. Press Home and End to confirm they jump to the first and last tab. Run a screen reader and confirm the tablist announces its label and the selected tab announces "selected."

If any step fails, you have a bug. None of these tests require special tooling.

What AccessGuard checks automatically

Our scanner detects the following on every page:

Accordion headers built from non-button elements that have click handlers (typically div or span with onclick). Buttons or elements with role="button" that toggle a panel but are missing aria-expanded. Elements with role="tab" that are missing aria-selected, aria-controls, or a corresponding tabpanel. Tablists missing an accessible name. Tabpanels that are not labelled by a tab. Multiple tabs in the same tablist all having tabindex="0" (the roving tabindex violation). Hidden panels whose focusable contents are still in the tab order.

What we do not catch automatically is keyboard interaction behavior: whether arrow keys actually move focus, whether Enter actually expands an accordion, whether focus moves correctly between tabs and panels. Those checks require running the page and simulating input, which is on our roadmap but not shipped yet.

Related reading

If you found this useful, the rest of the AccessGuard blog covers the patterns and rules that connect to this one:

The one-paragraph summary

Accordions are a stack of headings that expand and collapse. The simplest implementation is the native <details> and <summary> pair, which gives you focus, keyboard, and state for free — reach for it first. If you need custom animations or styling that <details> cannot provide, fall back to a real <button> wrapped in a real heading, with aria-expanded and aria-controls pointing to a panel that uses role="region" and the hidden attribute. Tabs are a row of mutually exclusive options with one panel visible at a time. Each tab has role="tab", aria-selected, and a roving tabindex; the panels have role="tabpanel" and aria-labelledby. Accordions use Tab and Enter; tabs use Arrow keys, Home, and End. Pick the right pattern for the structure of your content, build it with native elements where possible, and test it with your keyboard before you ship.

Start scanning for free

Join thousands of developers making the web more accessible.

Get started