Skip to main content
Want to level up in accessibility? Get my 10 day guide

JavaScript and Accessibility: Accordions

Note: this blog is an archive and not actively maintained. Some information may be out of date. If you'd like to see what I am working on or work with me in a consulting capacity, visit my website lindseykopacz.com.

Content Warning: This blog post contains gifs.

When I first wrote my post about JavaScript and Accessibility, I promised I would make it a series. I’ve decided to use my patreon to have votes on what my next blog post is. This topic won, and I’m finally getting more time to write about JavaScript!

So this topic I am going to go into a deep dive on how to make accordions accessible! Our focus is:

  • Accessing the accordion with a keyboard
  • Screen reader support

HTML Structure

I did a few pieces of research about the HTML structure. I read the a11y project’s link to Scott O’Hara’s Accordion code. I also read Don’s take about aria-controls - TL;DR he thinks they’re poop. I couldn’t escape reading the WAI-ARIA Accordion example as they set a lot of the standards. My hope is with all the information about what’s ideal, I can help talk through why everything is important here. It’s easy to get overwhelmed, and I’m here to help!

So if you read my post 3 Simple Tips to Improve Keyboard Accessibility, you may recall my love for semantic HTML.

If you need JavaScript for accessibility, semantic HTML makes your job significantly easier.

Many of the examples I found use semantic button elements for the accordion headings. Then the examples used div tags as siblings. Below is how my code starts:

Adding the ARIA attributes

I wrote that ARIA is not a replacement for semantic HTML in a previous post. New HTML features that come out are replacing ARIA all the time. In an ideal world, I would use the details element. Unfortunately, according to the Browser Compatibility Section, there is no support for Edge and IE11. Until browser support improves, I’ll be sticking to the “old fashioned” way of doing it. I’ll be adding ARIA for the context we need. I’m looking forward to seeing the compatibility expand to Edge!

First, I am going to add some aria-hidden attributes to the div to indicate the state of the accordion content. If the collapsed element is closed, we want to hide that content from the screen reader. Can you imagine how annoying it would be to read through the content you are not interested in?

- <div id="accordion-section-1">
+ <div id="accordion-section-1" aria-hidden="true">
...
...
- <div id="accordion-section-2">
+ <div id="accordion-section-2" aria-hidden="true">
...
...
- <div id="accordion-section-3">
+ <div id="accordion-section-3" aria-hidden="true">

The next thing we do is ensure that we have an aria-expanded attribute to the button. When we are on the button, it tells us if something is expanded or collapsed.

- <button id="accordion-open-1">
+ <button id="accordion-open-1" aria-expanded="false">
...
...
- <button id="accordion-open-2">
+ <button id="accordion-open-2" aria-expanded="false">
...
...
- <button id="accordion-open-3">
+ <button id="accordion-open-3" aria-expanded="false">

When it comes to ARIA for me, less is more. I am going to leave it at that and use JavaScript in a future section to toggle the states of the ARIA attributes.

Adding Some Styling

I’m not going to focus too much on the CSS specifics. If you need a CSS resource, Ali Spittel’s post CSS: From Zero to Hero and Emma Wedekind’s CSS Specificity post are great.

First, I add classes to the divs and the buttons for good measure.

- <button id="accordion-open-1" aria-expanded="false">
+ <button id="accordion-open-1" class="accordion__button" aria-expanded="false">
    Section 1
  </button>
- <div id="accordion-section-1" aria-hidden="true">
+ <div id="accordion-section-1" class="accordion__section" aria-hidden="true">

Then I add a bunch of styling to the buttons. I wrote this CodePen with SCSS.

(Quick note: for the triangles on the iframe, I used the CSS Triangle article from CSS tricks.)

I want to point out explicitly this code:

.accordion {
  // previous styling
  &__button.expanded {
    background: $purple;
    color: $lavendar;
  }
}

I want to specify what the button looks like when it was open. I like how it draws your eye and attention to the open section. Now that I see what they generally look like, I am going to add the styling to collapse them. Additionally, I’m adding some open styling.

  &__section {
    border-left: 1px solid $purple;
    border-right: 1px solid $purple;
    padding: 1rem;
    background: $lavendar;
+   max-height: 0vh;
+   overflow: hidden;
+   padding: 0;
  }

+ &__section.open {
+   max-height: 100vh;
+   overflow: auto;
+   padding: 1.25em;
+   visibility: visible;
+ }

Finally, let’s add some focus and hover styling for the buttons:

  $purple: #6505cc;
+ $dark-purple: #310363;
  $lavendar: #eedbff;
  &__button {
    position: relative;
    display: block;
    padding: 0.5rem 1rem;
    width: 100%;
    text-align: left;
    border: none;
    color: $purple;
    font-size: 1rem;
    background: $lavendar;

+   &:focus,
+   &:hover {
+     background: $dark-purple;
+     color: $lavendar;
+
+     &::after {
+       border-top-color: $lavendar;
+     }
+   }

A quick note: you could likely add styling by adding .accordion__button[aria-expanded="true"] or .accordion__section[aria-hidden="false"]. However, it is my personal preference using classes for styling and not attributes. Different strokes for different folks!

JavaScript toggling

Let’s now get to the fun part of toggling the accordion in an accessible way. First, I want to grab all the .section__button elements.

const accordionButtons = document.querySelectorAll('.accordion__button')

Then I want to step through every element of the HTML collection that JavaScript returns.

accordionButtons.forEach(button => console.log(button))
// returns <button id="accordion-open-1" class="accordion__button" aria-expanded="false">
//    Section 1
//  </button>
//  <button id="accordion-open-2" class="accordion__button" aria-expanded="false">
//    Section 2
//  </button>
//  <button id="accordion-open-3" class="accordion__button" aria-expanded="false">
//    Section 3
//  </button>

Then for each of those items, I want to toggle the class for the opening and closing for visual styling purposes. If you remember the .open and .expanded classes that we added before, here is where we toggle them. I am going to use the number in the ids that match up with each other to get the corresponding section for that button.

accordionButtons.forEach(button => {
  // This gets the number for the class.
  // e.g. id="accordion-open-1" would be "1"
  const number = button
    .getAttribute('id')
    .split('-')
    .pop()

  // This gets the matching ID. e.g. the
  // section id="accordion-section-1" that is underneath the button
  const associatedSection = document.getElementById(
    `accordion-section-${number}`
  )
})

Now we have the current value button in the callback and the associated section. Now we can get to toggling classes!

button.addEventListener('click', () => {
  button.classList.toggle('expanded')
  associatedSection.classList.toggle('open')
})

Toggling classes is not all we want to do. We also want to toggle the aria attributes. From the previous section, aria attributes communicate state to screen readers. Changing the classes shows what happened to a visual user, not to a screen reader. Next, I check if the button contains the class in one of those elements. If it does, I’ll swap the state for the aria-hidden and aria-expanded.

button.addEventListener('click', () => {
  button.classList.toggle('expanded')
  associatedSection.classList.toggle('open')

+ if (button.classList.contains('expanded')) {
+   console.log('open?')
+ }
})

The conditional fires after we set the classes, and if the class has expanded, it is open! So this is where we want to use the states and communicate it’s open.

button.addEventListener('click', () => {
  button.classList.toggle('expanded')
  associatedSection.classList.toggle('open')

  if (button.classList.contains('expanded')) {
    button.setAttribute('aria-expanded', true)
    associatedSection.setAttribute('aria-hidden', false)
  } else {
    button.setAttribute('aria-expanded', false)
    associatedSection.setAttribute('aria-hidden', true)
  }
})

Now we can open and close the accordion with the spacebar or the enter key!

Let’s test on our screen reader to be extra sure.

When I go through the accordions headers without opening them, they do not read them in the section. That’s a good thing! When I open it, I’m able to read it.

Progressive Enhancement

Now, I know how much we all rely on JavaScript loading, particularly with all the frameworks we use. Now that we know the functionality, let’s refactor the code a bit. The goal is to ensure anyone can access the accordion if JavaScript is not enabled or the user has connectivity issues.

My final touch is to

  • Keep all the accordion sections open by default (Adding an .open class to the HTML sections)
  • Remove the ‘open’ class once the JavaScript loads.
  • Add all the aria attributes with JavaScript and remove that from the HTML

I want to remove aria-expanded="false" and aria-hidden="true" from my buttons and sections, respectively. I also want to add the open class to the html, so it’s visually open by default.

- <button id="accordion-open-1" class="accordion__button" aria-expanded="false">
+ <button id="accordion-open-1" class="accordion__button">
    Section 1
  </button>
- <div id="accordion-section-1" class="accordion__section" aria-hidden="true">
+ <div id="accordion-section-1" class="accordion__section open">

I want to set those attributes and remove that class in the forEach loop of accordionButtons.

accordionButtons.forEach(button => {
+ button.setAttribute('aria-expanded', false);
  const expanded = button.getAttribute('aria-expanded');

Then I want to create an accordionsSections variable and do two things:

  • set the aria-hidden attribute
  • remove the .open class.
const accordionSections = document.querySelectorAll('.accordion__section')

accordionSections.forEach(section => {
  section.setAttribute('aria-hidden', true)
  section.classList.remove('open')
})

We’re done! Remember, we haven’t removed any of the other code or event listeners. We are just adding all those attributes in with JavaScript.

Conclusion

What did you think of this post? Did it help you? Are you excited for the <details> element? Cheers! Have a great week!