Accessible and animated expand/collapse components with Alpine.js and Tailwind CSS
Tags: tailwindcss, alpinejs, a11y
Published: 2021-08-26 19:27:24

Edit: Alpine now has an [x-collapse](https://alpinejs.dev/plugins/collapse) plugin that gives you something very similar to the functionality shown in this post. It initially had some accessibility issues, but I [raised a pull request to fix them](https://github.com/alpinejs/alpine/pull/2353).

----

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](https://developers.google.com/web/fundamentals/accessibility/semantics-builtin/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](https://www.aditus.io/patterns/accordion) and the [WAI-ARIA Authoring Practices 1.1 Accordion example](https://www.w3.org/TR/wai-aria-practices-1.1/examples/accordion/accordion.html), minus the optional keyboard arrow/home/end controls. It also incorporates a lot of ideas from Heydon Pickering's [Accessible Components](https://inclusive-components.design/collapsible-sections). 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.

{% codepen https://codepen.io/philw_/pen/GREJEgx default-tab=result %}

## 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.

```scss
// 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);
  }
}
```

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](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight).

Whenever the component is about to expand we can check our content element's [scrollHeight](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight) value and set a custom property on the element itself:

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

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

```javascript
// elem represents the element containing the collapsible content
elem.style.removeProperty('--collapse-height');
```

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:

```html
<div
  x-data="collapse" 
  class="collapse border py-3 px-5 space-y-3"
  @resize.window.debounce="updateHeight"
>
```

```javascript
// 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`);
  }
},
```

### Working around the accessibility issues

By changing the value of the `max-height` using a custom property and [Element.scrollHeight](https://developer.mozilla.org/en-US/docs/Web/API/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](https://www.scottohara.me/blog/2017/04/14/inclusively-hidden.html). 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](https://tailwindcss.com/docs/space) (based on [Heydon Pickering's 'lobotomised owl' technique](https://alistapart.com/article/axiomatic-css-and-lobotomized-owls/)). These classes are super useful for placing spacing between child elements, and they specifically ignore elements with the `hidden` attribute:

```scss
.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.
}
```

### 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.

```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,
  }
);
```

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:

```javascript
// 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`);
```

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

{% codepen https://codepen.io/philw_/pen/GREJEgx default-tab=html %}
{% codepen https://codepen.io/philw_/pen/GREJEgx default-tab=js %}
{% codepen https://codepen.io/philw_/pen/GREJEgx default-tab=css %}

…and a reminder of how it looks:

{% codepen https://codepen.io/philw_/pen/GREJEgx default-tab=result %}