Let’s Build A More Accessible Collapsible

September 6, 2021
CSS and SASS Tips and Tricks

It doesn’t matter how fancy it is, if it’s not accessible, it doesn’t matter.

Nate Northway, Here, 2021

Collapsible elements are useful pieces of UI that make digesting lots of content easier. Usually, they have a header with a control that, when clicked, displays more content below the header. Click that control again, and the additional content will be hidden. They’re great because they allow the reader to skim for important words. I like using them in my websites for definitions.

But I often find that these elements aren’t that accessibile, if at all, especially when they look good. Remember my motto: it doesn’t matter how fancy it is, if it’s not accessible, it doesn’t matter.

Starting Small: Markup

So we know what it is: a heading with a control to display more text. Cool. We can mark that up. We should wrap everything in a container. In that container we should have a container for the heading that will always be displayed no matter the state, as well as a container for the content that will be shown and hidden. In the first container, we’ll have our heading and control, and this whole container should be able to be clicked or interacted with. In the second container, we’ll have the content we wish to show & hide.

We also want some attributes added so that the JavaScript to control the display will work. We’ll use ids and data attributes to help the JS control things. We’ll want to identify the association between the header and the body, and we’ll also want to assign a unique id to the header element to use as an anchor.

<div class='collapsible'>
  <button class='collapsible-header' data-controls='demo-one-body' id='demo-one'>
    <span>Header</span>
    <i class='indicator fas fa-chevron-down'></i>
  </button>
  <div class='collapsible-body' id='demo-one-body'>
    <p>
      Lorem ipsum dolor sit amet consectetur adipisicing elit. 
    </p>
  </div>
</div>

To be frank, the above markup does nothing. But we can use this base for our styling. We’ll use JS later to control the display of the content. We’ll also use JS to toggle a class, which will change the direction the indicator points and help animate the smooth the display of the content. That’s all we’re going to look at with the CSS for now, I’ll remove all the stuff that makes it look good and let you be the decider on that.

.collapsible .collapsbile-header .indicator {
  transition: transform .3s ease;
}
.collapsible.active .collapsible-header .indicator {
  transform: rotate(180deg);
  transition: transform .3s ease;
}
.collapsible.active .collapsible-body {
  transition: height .3s ease;
}
.collapsible-body {
  transition: height .3s ease;
  height: 0;
  overflow-y: hidden;
  width: 100%;
}

This isn’t looking pretty yet, but I promise it will be functional. Here’s the JS. We start by finding all the controls, then adding event handlers to those controls. From there, we change the height of the body to either match the size of the container’s content, or be 0. We also toggle the active class of the parent collapsible element.

document.addEventListener('DOMContentLoaded', () => {
  let collapsibles = document.querySelectorAll('.collapsible-header');
  if (collapsibles) {
    collapsibles.forEach(el => {
      el.addEventListener('click', (e) => {
        e.preventDefault();
        //gather elements 
        var controls = document.getElementById(el.dataset.controls);
        var parent = el.parentElement;
        var content = controls.children[0];

        //get the height that the .collapsible-body should be 
        var setHeight = content.getBoundingClientRect().height;

        //toggle the 'active' class on the collapsible element
        parent.classList.toggle('active');

        //if the collapsible needs to be expanded
        if (parent.classList.contains('active')) {
          //expand the body
          controls.style.height = setHeight + "px"
        } else {
          //if the collapsible needs to be closed 
          //collapse the body 
          controls.style.height = 0;
        }
      })
    })
  }
})

Below, you’ll see all the above code in action. You’ll notice that I added a background color, font color, and some display preferences to fill it out. You’ll probably want to do this, too. Feel free to toggle this as much as you would like, I’m tracking the clicks on this element (jk, inspect the code and look at it, the script is inlined). Notice also that I prepended the classnames with “demoone-” so that I can do this another time on this page.

Lorem ipsum dolor sit amet consectetur adipisicing elit.

As it sits, this is functional, but it’s not accessible. Users can’t tell that it’s being focused when using it with a keyboard. There are no aria attributes, no way to visually discern if it’s focused, and the change is animated, which isn’t great for those who prefer not to see things animated. So, let’s address these issues, first in HTML by adding our aria attributes. Then, we’ll change styling by using the :focus-visible selector and the prefers-reduced-motion media query. Finally, we’ll change the aria attributes depending on the state of the element using JS. First, let’s start with the HTML.

To make this more accessible, we can use ARIA attributes. If you don’t know about these, I sure am glad you’re reading this. ARIA is a set of attributes that define ways to make web content more accessible. (Read more from MDN here). We can use these attributes to tell screen readers what is going on with our content. We’re looking at needing three.

We’ll use aria-controls on the .collapsible-header element, and assign the value of demo-two-body, which is the element that is being controlled. Next, we’ll also add aria-expanded, and assign a value of false, to indicate that the controlled element is not expanded. Finally, we move to the .collapsible-body element, where we’ll add aria-hidden, and assign a value of true to indicate that this element is visually hidden. The aria-hidden value on the controlled element and the aria-expanded value on the controlling element should always be the opposite of each other. We’ll control that with JS.

<div class='collapsible'>
  <button
    class='collapsible-header'
    data-controls='demo-two-body'
    aria-controls='demo-two-body'
    aria-expanded='false'
    id='demo-two'>
    <span>Header</span>
    <i class='indicator fas fa-chevron-down'></i>
  </button>
  <div 
      class='collapsible-body' 
      aria-hidden='true'
      id='demo-two-body'>
    <p>
      Lorem ipsum dolor sit amet consectetur adipisicing elit.
    </p>
  </div>
</div>

Now, to change styles to indicate focus, we can use a neat CSS pseudo-class, :focus-visible. We want to use this pseudo-class because it applies the specified styles when the element is focused with a keyboard, but not with a pointing device. So, it can be tabbed to and a visual indicator that it is the focused element can be set. This also makes it very obvious that the element can be focused with a keyboard to the browser – if we just use :focus, the browser skips right over the element when navigating with a keyboard. We also don’t want clicks to trigger these styles because mouse-users already see where the focus is – it’s impossible when using a keyboard to navigate to tell where the focus is when there are a lot of these lined up.

We’ll use this selector three times. We’ll change the background-color of the .collapsible-header element, and make it darker when it’s expanded. We’ll also add a border around the indicator.

//darken the background when active
.collapsible.active .collapsible-header:focus-visible {
  background-color: rgba(0,0,0,0.5); 
}
//darken the background when focused w/ keyboard
.collapsible .collapsible-header:focus-visible {
  background-color: rgba(0,0,0,.4); 
}
//add a border around the indicator
.collapsible .collapsible-header:focus-visible indicator {
  border: 2px solid blue;
}

We’re going to continue with the CSS though, because we didn’t touch on the animations. Sometimes, animations can trigger responses that are not pleasant. Browsers and systems can have a setting to hide animations, and to access that setting with CSS, we use the @media query prefers-reduced-motion. We can utilize that here to stop the animation of the .collapsible-body, as well as the indicator. We’ll add the media query to our CSS, and within it, set the transition property to none for the .indicator and .collapsible-body elements.

@media (prefers-reduced-motion) {
  .collapsible .collapsbile-header .indicator,
  .collapsible.active .collapsible-header .indicator,
  .collapsible.active .collapsible-body,
  .collapsible-body {
    transition: none;
  }
}

Now, we can talk about the JavaScript. We need to set the value of the aria-expanded attribute to true when the collapsible has the class of active. At the same time, we also need to set the value of the aria-hidden attribute on the .collapsible-body element to false. We need to set the opposite values when the collapsible doesn’t have the class of active. We already set up an event listener to handle a click, and within our event listener, we already have logic to determine if the element is active. We can use that code to set values.

...
if (parent.classList.contains('active')) {
  //expand the body
  controls.style.height = setHeight + "px"
  el.setAttribute('aria-expanded', 'true');
  controls.setAttribute('aria-hidden', 'false');
} else {
  //if the collapsible needs to be closed 
  //collapse the body 
  controls.style.height = 0;
  el.setAttribute('aria-expanded', 'false');
  controls.setAttribute('aria-hidden', 'true');
}
...

Our final JavaScript is below:

document.addEventListener('DOMContentLoaded', () => {
  let collapsibles = document.querySelectorAll('.collapsible-header');
  if (collapsibles) {
    collapsibles.forEach(el => {
      el.addEventListener('click', (e) => {
        e.preventDefault();
        //gather elements 
        var controls = document.getElementById(el.dataset.controls);
        var parent = el.parentElement;
        var content = controls.children[0];

        //get the height that the .collapsible-body should be 
        var setHeight = content.getBoundingClientRect().height;

        //toggle the 'active' class on the collapsible element
        parent.classList.toggle('active');

        //if the collapsible needs to be expanded
        if (parent.classList.contains('active')) {
          //expand the body
          controls.style.height = setHeight + "px"

          //Set the ARIA attributes to reflect the state
          el.setAttribute('aria-expanded', 'true');
          controls.setAttribute('aria-hidden', 'false');
        } else {
          //if the collapsible needs to be closed 
          //collapse the body 
          controls.style.height = 0;

          //Set the ARIA attributes to reflect the state
          el.setAttribute('aria-expanded', 'false');
          controls.setAttribute('aria-hidden', 'true');
        }
      })
    })
  }
})

And below, it’s all put together. Try to navigate the pen with your keyboard first, then with your mouse.

I hope this helped you learn something. This write-up was so much longer than what I expected and I totally spent more time on making it work nice for the demos and writing this post than I did actually developing this component. Please email me if you have any questions, want to add any comments, or just want to call me names. Happy coding!

See the Pen Somewhat more accessible collapsibles by Nate Northway (@the_Northway) on CodePen.

No Comments...yet

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Development Software and Tools

From March 4, 2021

I use a lot of tools to build websites. I try to keep this list updated as my workflow adapts.

Read This Article
Next Post

Media Queries in JavaScript

From December 3, 2021

If JavaScript can animate things, should it also be able to tell if it should animate things?

Read This Article