Skip to main content Skip to navigation
All Posts All Tags

Tutorial: Let's build an accessible disclosure (show/hide accordion)

A "disclosure" means any user interface (UI) pattern that reveals additional content following some kind of user interaction. Common examples are tooltips, mobile menu toggles and accordions.

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:

  1. What happens when JavaScript (JS) fails to load or users turn it off? We will want to see all of the content
  2. 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 and title 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
const disclosures = document.querySelectorAll('.js-disclosure');

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 not 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 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 very recently 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.

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:

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!