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.
Table of Contents
- 1. Why accordions and tabs need their own ARIA patterns
- 2. The accordion pattern: aria-expanded, aria-controls, and button semantics
- 3. The tabs pattern: role="tablist", role="tab", and role="tabpanel"
- 4. Implementing arrow key navigation for tabs correctly
- 5. Enter and Space on accordions: why native buttons solve this automatically
- 6. Practical example: product description tabs on the Hyvä product page
- 7. Practical example: FAQ accordion with multiple panels open at once
- 8. Test checklist for both patterns
- 9. Common deviations from the standard pattern and their consequences
- 10. Summary
- 11. FAQ
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.