DEV Community

loading...

Accessible and animated expand/collapse components with Alpine.js and Tailwind CSS

Phil Wolstenholme
I'm an accomplished developer particularly focussed on accessibility and frontend web performance. Outside of work I'm interested in science, the environment, bouldering, and bikes.
・Updated on ・7 min read

Disclaimer: Animating max-height (or height) has a high performance cost and will lead to page jankiness, especially on low-end or mid-range devices, or on complex/large pages. It's best not to animate things like accordions or expand/collapse components, but if you must, then you should do make sure you do it in an accessible way.

I've been playing around with how to have an animated expand/collapse component, while also avoiding the accessibility issues of many max-height based solutions that sometimes rely on only using overflow: hidden and max-height: 0 to visually hide content (πŸ‘Ž), instead of an approach that removes content from the accessibility tree so that it isn't focusable during keyboard navigation or announced by things like screen readers.

I have settled on an approach that uses a CSS custom property to trigger the transition of the max-height property (set to the correct height for the content via a small amount of JavaScript) but then uses a transitionend event listener to set a hidden attribute on the content to make sure it is hidden from the accessibility tree as well as hidden visually. This approach means we can let the transition finish, then make sure that content inside the collapsed component won't be announced by screen readers or be tabbable to by users navigating the page via the Tab key.

Here's how it looks

The markup is based on this pattern by Aditus and the WAI-ARIA Authoring Practices 1.1 Accordion example, minus the optional keyboard arrow/home/end controls. It also incorporates a lot of ideas from Heydon Pickering's Accessible Components. I'm using some things like aria-controls and aria-labelledby that I don't explain in this article, but are covered by the previous links.

Here's how it works

It's not possible to animate between an explicit/numeric height value like 0 or 0px to the auto value. Because of this, one workaround is to animate the max-height property from 0 to a high number that you know is more than your component needs, for example, 1000px. This has a few issues:

  • What if content that you can't control (e.g. from a CMS) means the component ends up needing more than 1000px worth of height? If this happens the content will be visually hidden as the max-height will clip the component.
  • What if a viewport size (e.g. a very narrow viewport) means the component ends up needing more than 1000px worth of height? It'll be clipped again.
  • If your component needs much less than 1000px worth of height then the animation will feel very slow - the browser will time the transition as if your element was going from 0px to 1000px, but if the actual height stops at 250px then the transition will feel three times longer than it should.
  • Finally, and most importantly from an accessibility point of view, setting max-height to 0 is not a safe way of hiding content. It visually hides it, sure, but it does not hide it properly – focusable elements like form inputs or links inside the visually hidden content can still be tabbed to with the keyboard, for example.

We can work around all these issues with some JavaScript.

Working around the height issues

We need a way to toggle the max-height value between 0 and a number that represents the exact height that the expanded content will need - with no guessing by using large numbers like 1000px!

To start with, we give our collapse component's content element a max-height value using a CSS custom property via var(). If the --collapse-height custom property is not defined then a fallback value of 0 is used. By adding and removing the --collapse-height custom property with JavaScript we will be able to switch the max-height between a value that matches its content (more on this later), and a value of 0 to visually hide our content. By default, we won't provide a --collapse-height value, so our component will appear collapsed by default.

// The @layer directive is only needed for Tailwind users
// and is optional even in that case. See https://tailwindcss.com/docs/functions-and-directives#layer
// for more information.
@layer components {
  .collapse__content {
    max-height: var(--collapse-height, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

We also set overflow to hidden on our content container, and we set a transition that will cover the max-height property. I'm doing this with Tailwind (overflow-hidden transition-all duration-300), but you can do this with whatever flavour of CSS that takes your fancy.

When it's time to visually reveal the content, we need to work out the exact height that the content needs. This avoids any guesses and helps us prevent the clipping and slow transition issues that I mentioned earlier. We can do this via JavaScript and querying Element.scrollHeight.

Whenever the component is about to expand we can check our content element's scrollHeight value and set a custom property on the element itself:

// elem represents the element containing the collapsible content
elem.style.setProperty('--collapse-height', `${elem.scrollHeight}px`);
Enter fullscreen mode Exit fullscreen mode

When the component is collapsed, we remove the custom property to switch the component back to using the fallback max-height value of 0:

// elem represents the element containing the collapsible content
elem.style.removeProperty('--collapse-height');
Enter fullscreen mode Exit fullscreen mode

This isn't completely perfect. For example, if someone expanded the component but then resized their browser window or changed their device orientation then there would be a chance that the content could be clipped. This would happen if the --collapse-height value no longer represented the element's scrollHeight value. We can work around this issue by listening for a resize event on window (don't forget to debounce or throttle it!) and updating the --collapse-height value.

I've done this in my Alpine.js example like so:

<div
  x-data="collapse" 
  class="collapse border py-3 px-5 space-y-3"
  @resize.window.debounce="updateHeight"
>
Enter fullscreen mode Exit fullscreen mode
// This function is defined inside Alpine.data() for my component.
updateHeight() {
  if (this.expanded) {
    const elem = this.$refs.content;
    elem.style.setProperty('--collapse-height', `${elem.scrollHeight}px`);
  }
},
Enter fullscreen mode Exit fullscreen mode

Working around the accessibility issues

By changing the value of the max-height using a custom property and Element.scrollHeight we have a solution that visually works, but we still need to make it accessible.

Right now, content inside the collapsible content would still be reachable by a screen reader or keyboard navigation, and that doesn't match up with the experience that sighted users have. We need a way to hide the content completely - but only once the transition has finished.

There are lots of ways we can hide content in an accessible way. We could add/remove display: none; or visibility: hidden; in CSS, or we could use the hidden HTML attribute. The hidden attribute is very similar to using display: none; except it has a lower CSS specificity because the functionality is provided by the browser's user-agent CSS file, rather than the site's CSS.

I like using the hidden attribute as it plays nicely with Tailwind's 'space between' classes (based on Heydon Pickering's 'lobotomised owl' technique). These classes are super useful for placing spacing between child elements, and they specifically ignore elements with the hidden attribute:

.space-y-8>:not([hidden])~:not([hidden]) {
  // CSS to add 8 units of spacing between elements 
  // that have a preceeding sibling and are not hidden.
}
Enter fullscreen mode Exit fullscreen mode

How to fully hide the content but only once the transition has finished?

When the component is contracted we need to wait for the transition to finish before applying the hidden attribute. This way our transition has a chance to run before the hidden attribute causes the element to be completely hidden.

We can do this by listening for the transitionend event to toggle the hidden attribute each time the expand/contract transition is finished. I picked this over a setTimeout because it means if the CSS for the transition-duration is ever changed no one will need to remember to also update the JavaScript.

elem.addEventListener(
  'transitionend',
  (e) => {
    // We need to make sure the event hasn't come from a child element
    // and bubbled up to our element.
    if (e.target === elem) {
      // Mark the element as hidden so its contents will be
      // hidden from assistive tech like screen readers or
      // keyboard navigation.
      elem.hidden = true;
      this.expanded = false;
    }
  },
  {
    once: true,
  }
);
Enter fullscreen mode Exit fullscreen mode

I use once: true in the options for the event listener to create a disposable event listener that will only fire once. I also check that the target of the event matches our content element so we don't accidentally fire the event if a transitionend event is fired by unrelated content inside the component.

When the component is expanded we need to remove the hidden property so we can calculate our element's height. The max-height CSS that we wrote earlier means we can remove the hidden property without worrying about the content suddenly becoming visible:

// Unhide our element so we can calculate its dimensions.
// It will still be visually hidden because of the maxHeight
// of 0.
elem.hidden = false;
// Set a --collapse-height property that matches the elements height.
// This will cause the browser to animate the opening of the
// element.
elem.style.setProperty('--collapse-height', `${elem.scrollHeight}px`);
Enter fullscreen mode Exit fullscreen mode

Here's the full code for an Alpine.js implementation of this sort of component



…and a reminder of how it looks:

Discussion (0)