Tutorial: Let's build an accessible disclosure (show/hide accordion)
Updated on — 11 minutes to read
In this guide, we'll explore how to create an accessible pattern for expandable content.
By the end, you'll have a solid pattern in your pocket you can adapt for accordions, mobile navigation toggles (often called 'burger' menus) and any other components that reveal content following user interaction. The pattern I'm teaching should work for all users, no matter what modality they're using (keyboard, screenreader, switch control, voice control or any others).
💡 I'll do separate posts specifically looking at accordions and mobile navigation, as there is some extra 'nice to have' functionality you may want in those!
Let's get started!
Accessibility (A11y) Essentials
It's really important to:
- open disclosures with a button click (not hover or mouseover)
- use
aria-expanded
to communicate state to assistive tech - use
aria-controls
to programmatically link buttons with the content they're controlling for the JAWS screen reader - give the button an accessible name (usually with child content, although it can be done with
aria-label
) - Test any transitions or animations with assistive technologies
- Turn off or reduce animations for those who prefer reduced motion
HTML Structure
Start by setting up your HTML structure with appropriate meaningful elements. Disclosures should always be triggered by a button click, which reveals content within a sibling element, such as a div.
The button must have an accessible name, which we'll do by placing some text inside the button.
Using aria-expanded
on the button is also essential. This is what will communicate 'state' to screen readers, announcing the button as something like "Button, Expandable, Collapsed" or "Button, Expandable, Expanded".
Optionally, it's good to also include aria-controls
, which will point to the unique ID of the content being toggled. This isn't used by all screen readers, but is used by one called 'JAWS'.
Lastly, we will want to give the button an icon to help people understand that this will trigger some expanding content. Note, this is decorative so should be hidden from the accessibility tree.
Here's what this HTML looks like:
<div class="disclosure js-disclosure">
<button
aria-expanded="false"
class="disclosure__button js-disclosure-btn"
aria-controls="content"
>
<!-- Accessible name goes here-->
Disclosure trigger
<!-- An icon to indicate this is a disclosure -->
<svg
class="disclosure__icon"
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 10 6"><polygon points="8.97 0 5.01 3.9 1 0.03 0 1.03 4.96 6 10 1.08 8.97 0"></polygon></svg>
</button>
<div class="disclosure__content" id="content">
<!-- Additional content goes here -->
<p>Revealed content!</p>
</div>
</div>
But, there are a few issues with this HTML:
- What happens when JavaScript (JS) fails to load or users turn it off? We will want to see all of the content
- If we make the content open by default for no-JS users, then hidden once JS loads, it will introduce Cumulative Layout Shift (CLS), resulting in poor site performance.
Styling with a11y in mind
Write whatever styles you like to make your disclosure component look nice. The important point to note is that we're using the aria-expanded attribute as the hook to trigger content visibility changes.
I prefer this approach over toggling classes or data-attributes because it makes it much easier to spot accessibility bugs! If aria-expanded isn't toggling correctly, the UI will visibly break, as nothing will be revealed, forcing you to fix the root cause (that aria-attribute's state).
// Some basic styles for the demo
.disclosure {
border: 1px solid #ccc;
padding: 10px;
max-width: 50rem;
}
.disclosure__content {
margin-top: 10px;
}
// Size the icon proportionally to the button's font size
.disclosure__icon {
height: 1em;
width: 1em;
fill: currentcolor;
transition: transform 0.3s ease-out;
}
.disclosure__button {
background-color: #222;
padding: 0.25em 0.5em;
color: #fff;
border: 0;
cursor: pointer;
// align the icon correctly:
display: flex;
align-items: center;
justify-content: space-between;
gap: 1ch;
// manage keyboard focus
&:focus-visible {
outline: solid orange 3px;
outline-offset: 3px;
}
// ! Important functionality hooks
&[aria-expanded="false"] {
+ .disclosure__content {
display: none;
}
}
&[aria-expanded="true"] {
+ .disclosure__content {
display: block;
}
.disclosure__icon {
transform: rotate(-180deg);
}
}
}
The above should be fairly self-explanatory. If the button's aria-expanded state is set to "true" we want to reveal the content. If it's false, we want to hide the content.
Add the JavaScript
When adding JavaScript it's helpful to break down what you're trying to do into manageable steps. Here we need to
- find all disclosure components on the page
- update the custom
data-state
attribute now that JS has loaded - remove the
disabled
andtitle
attributes from the disclosure buttons - listen for clicks on those buttons
- toggle the
aria-expanded
boolean values on any disclosure button as it is clicked
Remember, all we are doing in JS is toggling that aria attribute's value so that the state is correctly announced to assistive technology and the styling can use that state. We shouldn't be setting inline styles or toggling anything else. Let your CSS handle the content visibility changes as a styling concern.
// disclosure.js
// get all disclosure components on the page
const disclosures = document.querySelectorAll('.js-disclosure');
// Simple toggle function
function toggleDisclosure() {
const isExpanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', !isExpanded);
}
function init() {
// early return if no disclosures present on page
if (!disclosures.length) {
return
}
// update state now JS has loaded
disclosures.forEach(component => {
component.dataset.state = 'ready';
const disclosureButtons = component.querySelectorAll('.js-disclosure-btn');
disclosureButtons.forEach(btn => {
// initialise button attributes now JS has loaded
btn.removeAttribute('title');
btn.removeAttribute('disabled');
// listen for clicks
btn.addEventListener('click', toggleDisclosure);
});
});
}
// export as JS module
// NOTE: Optional! Call init() instead if not using modules
export default init;
The HTML-only Disclosure
You may be thinking, "Why can't I use the details element instead" for all disclosures? You could do this, but I first recommend you read more about it and make sure it is appropriate for what you are trying to build.
You can read more about the details disclosure element on MDN. And more about its inconsistent browser and assistive technology behaviour thanks to Scott O'Hara and Adrian Roselli.
A note on accordions
If you want to build an accordion component specifically, Adrian Roselli has written a post about how you can adapt the details element for this purpose: Progressively enhanced HTML accordion.
Note, the JS in Adrian's example needs modernising, as he points out in the article. At the time of writing this, his example does not 'toggle' the accordion items either — It does not close and open one on click as I believe it should.
And actually, I disagree that an accordion must always close the others on open. That will depend on the technical requirements for which you are building. I see no problem from a usability perspective with making each accordion item open and close independently, and in fact, I prefer that as a user. It gives me explicit control, it means I can read and compare two sets of revealed content at once, and it reduces unexpected scrolling / jumping around.
See how there are many ways to approach these UI patterns?! It's fine to come to your own opinions on some of these finer details, as long as you consider the options and test thoroughly.
A note on accordions
If you want to build an accordion component specifically, Adrian Roselli has written a post about how you can adapt the details element for this purpose: Progressively enhanced HTML accordion.
Note, the JS in Adrian's example needs modernising, as he points out in the article. At the time of writing this, his example does not 'toggle' the accordion items either — It does not close and open one on click as I believe it should.
And actually, I disagree that an accordion must always close the others on open. That will depend on the technical requirements for which you are building. I see no problem from a usability perspective with making each accordion item open and close independently, and in fact, I prefer that as a user. It gives me explicit control, it means I can read and compare two sets of revealed content at once, and it reduces unexpected scrolling / jumping around.
See how there are many ways to approach these UI patterns?! It's fine to come to your own opinions on some of these finer details, as long as you consider the options and test thoroughly.
A note on mobile navs
⚠️ The details element should not be used for mobile navigation disclosures. On that point, accessibility professionals are agreed.
Instead, follow a standard JS-disclosure pattern using buttons. There are various options for how to handle the default no-JS versions of mobile menus, which I'm not going into here.
A note on mobile navs
⚠️ The details element should not be used for mobile navigation disclosures. On that point, accessibility professionals are agreed.
Instead, follow a standard JS-disclosure pattern using buttons. There are various options for how to handle the default no-JS versions of mobile menus, which I'm not going into here.
Testing and Refinement
As with all things, I always recommend you test UI patterns as much as you can across various browsers and devices. Verify that the component functions seamlessly and is accessible to all users, including those using different user-agent and screen-reader pairings.
Summary and Demo
I hope this guide has helped you to better understand disclosures in general, and equipped you with a decent progressively enhanced starter pattern.
Here is how our end solution looks:
CodePen code example: Assistive tech quiz
As you integrate this pattern (or others like it) into your projects, keep accessibility at the forefront of your mind and rigorously test your work. Good luck!