Edit: Alpine now has an x-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.
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 themax-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 from0px
to1000px
, but if the actual height stops at250px
then the transition will feel three times longer than it should. - Finally, and most importantly from an accessibility point of view, setting
max-height
to0
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);
}
}
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`);
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');
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"
>
// 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 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.
}
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,
}
);
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`);
Here's the full code for an Alpine.js implementation of this sort of component
…and a reminder of how it looks:
Top comments (0)