Implementing Accessible Accordions and Tabs
AI generated
A11Y
WCAG
Accessibility
Accessible Accordions and Tabs
Implementing the correct ARIA patterns

Accordions and tabs are among the most frequently misimplemented interface patterns on the web, because they look visually simple while requiring, underneath, a precisely defined keyboard interaction and their own ARIA role structure. An accordion that only reacts to a click on a div, or tabs that know no arrow key navigation, appear to work perfectly for mouse users while remaining unusable or at least confusing for keyboard and screen reader users.

10 min read role="tablist" aria-expanded WAI-ARIA Authoring Practices

1. Why accordions and tabs need their own ARIA patterns

Both components appear simple at first glance: a click shows or hides content. This simplification ignores that screen reader users, without semantic markup, cannot know that a given element is expandable at all, what state it is currently in, open or closed, active or inactive, and how many other similar elements exist in the group. A div with a click handler visually looks identical to a correctly marked up button, but semantically offers nothing at all.

The WAI-ARIA Authoring Practices define precise role, state, and keyboard requirements for both patterns, which are consistently supported across practically every screen reader, provided they are followed exactly. Deviating from these requirements, for example a custom keyboard logic instead of the expected arrow key navigation for tabs, regularly causes confusion, because experienced screen reader users rely on the standard behavior and, when it deviates, initially assume their own operation is at fault.

2. The accordion pattern: aria-expanded, aria-controls, and button semantics

An accessible accordion consists of a heading, usually h3, wrapping a button, plus an associated content region. The button carries aria-expanded with the current state, true for open and false for closed, as well as aria-controls, which references the associated content region by ID. The content region itself does not need a special ARIA role, but should be uniquely referenceable via that ID.

Using a native button element instead of a div or span with a click handler is crucial. A native button element is automatically reachable via Tab, automatically responds to Enter and Space, and already carries the role 'button' in the accessibility tree, without needing to add it manually via a role attribute. If a non-button element is used anyway, role="button", tabindex="0", and manual keyboard handlers for Enter and Space have to be added, which is more error prone than the native solution.


<!-- Accessible accordion item: native button element -->
<h3>
  <button type="button"
          :aria-expanded="open.toString()"
          aria-controls="panel-shipping"
          @click="open = !open"
          class="w-full flex items-center justify-between py-4 text-left">
    <span>Shipping Costs</span>
    <svg :class="{ 'rotate-180': open }" class="h-5 w-5 transition-transform" aria-hidden="true">
      <!-- chevron icon -->
    </svg>
  </button>
</h3>
<div id="panel-shipping" x-show="open" role="region" aria-labelledby="panel-shipping-heading">
  <p>Shipping costs are 4.90 euros within Germany.</p>
</div>

3. The tabs pattern: role="tablist", role="tab", and role="tabpanel"

An accessible tab widget consists of three roles working together. The wrapping container of the tab buttons gets role="tablist", each individual tab button gets role="tab" together with aria-selected, which marks the active tab, and each content region gets role="tabpanel". Additionally, each tab points via aria-controls to its associated panel, and each panel via aria-labelledby back to its tab.

A frequently overlooked detail: only the active tab carries tabindex="0", all inactive tabs get tabindex="-1". The tab widget as a whole is therefore only a single tab stop within the global page tab order, while navigation between the individual tabs inside the widget happens via arrow keys, not by repeatedly pressing Tab. This pattern is called Roving Tabindex and is central to the correct keyboard experience of tab widgets.


<!-- Tab list with roving tabindex: only the active tab is reachable via Tab -->
<div role="tablist" aria-label="Product information">
  <button role="tab"
          id="tab-description"
          :aria-selected="activeTab === 'description'"
          :tabindex="activeTab === 'description' ? 0 : -1"
          aria-controls="panel-description"
          @click="activeTab = 'description'">
    Description
  </button>
  <button role="tab"
          id="tab-specs"
          :aria-selected="activeTab === 'specs'"
          :tabindex="activeTab === 'specs' ? 0 : -1"
          aria-controls="panel-specs"
          @click="activeTab = 'specs'">
    Specifications
  </button>
</div>
<div id="panel-description" role="tabpanel" aria-labelledby="tab-description" x-show="activeTab === 'description'">
  <!-- description text -->
</div>

4. Implementing arrow key navigation for tabs correctly

Within a focused tab widget, screen reader users expect the right and left arrow keys, up and down for vertically arranged tabs, to move to the next or previous tab and activate it automatically. Additionally, Home and End should jump to the first and last tab respectively. In most implementations, activation happens automatically on focus change, without needing to also press Enter, a behavior called 'automatic activation' in the WAI-ARIA Authoring Practices, and the recommended default for tabs that do not trigger expensive content computation on each panel switch.

In Alpine.js, this navigation can be implemented compactly via @keydown.right and @keydown.left on the tablist container, combined with a method that computes the next activatable tab index and focuses its DOM element. It is important that on every arrow key change, both the Alpine state activeTab is updated and browser focus is explicitly set on the new tab button, since the two can otherwise drift out of sync.


// tabs.js: arrow key navigation with automatic activation
function productTabs(tabs) {
  return {
    tabs,
    activeTab: tabs[0].id,
    activateByOffset(offset) {
      const currentIndex = this.tabs.findIndex(t => t.id === this.activeTab);
      const nextIndex = (currentIndex + offset + this.tabs.length) % this.tabs.length;
      this.activeTab = this.tabs[nextIndex].id;
      this.$nextTick(() => {
        document.getElementById(`tab-${this.activeTab}`)?.focus();
      });
    },
    next() { this.activateByOffset(1); },
    prev() { this.activateByOffset(-1); },
  };
}

5. Enter and Space on accordions: why native buttons solve this automatically

For accordions, the keyboard expectation is simpler than for tabs: Enter and Space should toggle the state of the focused accordion button, open becomes closed and vice versa. A native button element triggers this behavior automatically, without needing a custom keydown handler, because browsers respond to both Enter and Space with a click event for buttons by default.

The reason mistakes still happen regularly: many accordion implementations do bind the click handler to a button element, but accidentally prevent the default Space behavior by intercepting the Space event globally, for example for a search shortcut feature, without checking whether focus currently sits on an interactive element like an accordion button. Global keyboard shortcuts like this must always check whether event.target is a native interactive element before suppressing default behavior.

6. Practical example: product description tabs on the Hyvä product page

On many Magento product pages, description, technical specifications, and reviews are displayed as tabs below the product images. A common misimplementation uses plain div elements with Alpine.js click bindings without any ARIA roles at all, which remains entirely unnoticeable for sighted mouse users but appears to screen reader users as an unstructured collection of text blocks, with no recognizable relationship between tab label and associated content.

The correct implementation combines the tabs ARIA pattern from the previous section with the existing Hyvä block structure: each tab's content remains a standalone Hyvä block, but is controlled via a shared Alpine x-data object at the level of the wrapping container, so the roving tabindex logic is managed centrally in one place instead of being duplicated in every individual block template.


<!-- product/view/tabs.phtml: Hyvä block structure with ARIA tabs -->
<div x-data="productTabs([
       { id: 'description', label: 'Description' },
       { id: 'specs', label: 'Specifications' },
       { id: 'reviews', label: 'Reviews' },
     ])">
  <div role="tablist" aria-label="Product information"
       @keydown.right.prevent="next()" @keydown.left.prevent="prev()"
       @keydown.home.prevent="activeTab = tabs[0].id"
       @keydown.end.prevent="activeTab = tabs[tabs.length - 1].id">
    <template x-for="tab in tabs" :key="tab.id">
      <button role="tab" :id="`tab-${tab.id}`"
              :aria-selected="activeTab === tab.id"
              :tabindex="activeTab === tab.id ? 0 : -1"
              :aria-controls="`panel-${tab.id}`"
              @click="activeTab = tab.id" x-text="tab.label"></button>
    </template>
  </div>
  <div id="panel-description" role="tabpanel" aria-labelledby="tab-description"
       x-show="activeTab === 'description'">
    <?= $block->getChildHtml('product.info.description') ?>
  </div>
</div>

7. Practical example: FAQ accordion with multiple panels open at once

FAQ sections often differ from classic accordions in that multiple panels are allowed to be open at the same time, instead of opening one panel automatically closing all others. The ARIA pattern stays identical, every button still carries aria-expanded and aria-controls, there is simply no central logic that closes all other panels automatically when one opens.

In Magento, the native HTML elements details and summary are also often a good fit for FAQ sections, since they are already fully keyboard and screen reader accessible without any JavaScript and without manual ARIA attributes, because the browser automatically provides aria-expanded-equivalent behavior. The downside is more limited visual control over the expand animation compared to an Alpine.js solution, which is why many Hyvä projects opt for the manual button variant when an animated height transition is desired.


<!-- FAQ accordion: multiple panels can be open at the same time -->
<div x-data="{ openItems: new Set() }">
  <template x-for="item in faqItems" :key="item.id">
    <div>
      <h3>
        <button type="button"
                :aria-expanded="openItems.has(item.id)"
                :aria-controls="`faq-panel-${item.id}`"
                @click="openItems.has(item.id) ? openItems.delete(item.id) : openItems.add(item.id)">
          <span x-text="item.question"></span>
        </button>
      </h3>
      <div :id="`faq-panel-${item.id}`" x-show="openItems.has(item.id)" x-text="item.answer"></div>
    </div>
  </template>
</div>

8. Test checklist for both patterns

For accordions: put the mouse away, reach every accordion button via Tab, open and close it with Enter and Space, and check whether the screen reader announces the state change, which happens automatically with a correctly set aria-expanded, without needing an additional live region. For tabs: check that only a single tab stop exists for the entire widget in the global tab order, that arrow keys inside the widget move to and activate the next tab, and that Home and End lead to the first and last tab respectively.

An additional, often overlooked test concerns the panel content itself: after switching to a different tab, focus should generally not automatically jump into the new panel, but stay on the activated tab button, so the user deliberately decides via their own Tab press when to navigate into the panel content, instead of being unexpectedly ejected out of the tab widget.

9. Common deviations from the standard pattern and their consequences

One common deviation is using role="tab" without an accompanying role="tablist" on the container, which causes screen readers to announce each tab as such but be unable to provide grouping information, for example 'tab 2 of 4'. A second common deviation is an accordion where aria-expanded is technically present but never updated dynamically, because it was set as a static attribute during server rendering and never synchronized via JavaScript with the actual visibility state.

Both deviations are partially detectable with automated tools like axe-core, especially the missing tablist role, while the missing dynamic update of aria-expanded is often only caught in a manual test with a real screen reader, because axe-core checks the static HTML state at the time of the test, not the behavior after a user interaction.

Pattern Key role/attribute Keyboard interaction Typical Magento mistake
Accordion aria-expanded on the button Enter/Space, natively via button element aria-expanded set statically, never updated
Tabs (horizontal) role="tablist" on the container Arrow left/right, Home/End role="tab" without a wrapping tablist
Tabs (roving tabindex) tabindex 0 only on the active tab Only one tab stop in the global tab flow All tabs with tabindex=0, requiring multiple stops
FAQ accordion (multiple open) aria-controls per panel Enter/Space per panel independently Click wrongly closes all other panels
Native details/summary No ARIA needed, browser native Enter/Space automatically supported Unnecessarily overloaded with extra ARIA

Mironsoft

WCAG audits, accessible Magento shops, and training

Not sure whether the shop is actually accessible?

We audit existing Magento shops against WCAG 2.2, fix concrete barriers in the Hyvä frontend, and train teams so accessibility stays anchored in the development process for good.

WCAG Audit

Systematically review the shop against WCAG 2.2 AA, with a prioritized issue list.

Fixing Barriers

Concrete implementation: keyboard operability, screen reader support, contrast, forms.

Team Training

Raise developer and editor awareness for accessible implementation day to day.

10. Summary

Accessible Accordions and Tabs: The Essentials at a Glance

Accordion core

Native button element with aria-expanded and aria-controls, dynamically updated on every state change, not statically fixed during server rendering.

Tabs core

Three-part role pattern role=tablist, role=tab, role=tabpanel combined with roving tabindex, so only the active tab is reachable via the Tab key.

Keyboard standard

Arrow keys navigate and automatically activate tabs, Home and End jump to the start and end, Enter and Space toggle accordion panels.

Most common mistake

ARIA roles without accompanying keyboard logic or without dynamic attribute updates, leaving the markup technically present but functionally ineffective.

11. FAQ: Accessible Accordions and Tabs: The Essentials at a Glance

1Does an accordion button have to be a native button element?
It is by far the more robust solution, because native buttons are automatically reachable via Tab and automatically respond to Enter and Space. Alternatives with div and role=button require manually rebuilt keyboard handlers and are more error prone.
2What is roving tabindex and why do tabs need this pattern?
Roving tabindex means only a single element in a group carries tabindex=0 at any given time, all others tabindex=-1. This makes the entire tab widget a single stop in the global tab order, while arrow keys switch between tabs inside the widget.
3Do tabs need to be activated immediately on every focus change?
This is the recommended 'automatic activation', as long as the panel switch does not trigger expensive computation. For expensive content computation, 'manual activation' is also allowed, where Enter is needed to actually activate the tab.
4Can multiple panels be open at once in an FAQ accordion?
Yes, this is a legitimate, commonly used pattern and does not deviate from the ARIA pattern. Every button still independently carries its own aria-expanded, there is simply no logic that automatically closes other panels.
5Is the native details/summary element enough as a replacement for a custom-built accordion?
For many cases, yes, it is fully keyboard and screen reader accessible without any additional ARIA. The downside is more limited control over animated height transitions compared to an Alpine.js solution.
6Why does axe-core report a tabs error even though role=tab is correctly set?
axe-core checks, among other things, whether role=tab is accompanied by a wrapping role=tablist. If this container role is missing, the error is reported, even if the individual tab elements are correctly annotated on their own.
7Does aria-expanded need to be dynamic in server-side rendering?
Yes, a value set statically in the HTML that is never synchronized with the actual visibility state misleads screen readers about the true state as soon as the panel state changes via JavaScript.
8Does focus automatically jump into the new panel content when switching tabs?
In most implementations, no, and that is correct. Focus stays on the activated tab button, so the user deliberately decides via their own Tab press when to navigate into the panel content.
9How do I test arrow key navigation for tabs without a screen reader?
Put the mouse away, focus a tab via the Tab key, and then use only the arrow keys to switch between tabs. Home and End should jump to the first and last tab respectively.
10What happens if a global keyboard shortcut interferes with an accordion button's Space behavior?
If the Space event is intercepted globally for another feature without checking whether focus is on an interactive element, it wrongly prevents the accordion state from toggling. Global shortcuts must check event.target before executing.