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
| Component | What it is | Keyboard | Build with |
|---|---|---|---|
| Disclosure | One button that shows and hides one section | Tab to it, Enter or Space toggles | details and summary, or a button with aria-expanded |
| Accordion | A stack of disclosures with headings, often in an FAQ | Same as disclosure; arrow keys optional | details elements, or headings containing buttons |
| Tabs | A set of panels where exactly one is visible | Tab into the tab list, arrow keys between tabs | role="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.
<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>
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.
<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.
<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-expandedtells users whether the section is open.hiddenremoves 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:
.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
buttonwithrole="tab",aria-selectedandaria-controls. - Each panel has
role="tabpanel"and is named by its tab witharia-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.
HomeandEndgo to the first and last tab. Tabfrom the selected tab moves into the panel.
The markup
<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
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).
[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
- Can you reach and operate every disclosure button with
EnterandSpace? - Does a screen reader announce "expanded" and "collapsed", and update after each press?
- In tabs, does
Tabreach only the selected tab, then move into its panel? - Do arrow keys,
HomeandEndmove between tabs and show the right panel? - Does a screen reader announce "tab, selected, 1 of 3" or similar?
- 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
detailsandsummarywhere they fit; they need no ARIA or JavaScript. - For custom accordions, put a
buttonwitharia-expandedinside each heading and togglehiddenon the panel. - Tabs need
tablist,tabandtabpanelroles,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.