Transitioning Hidden Elements
At 4/19/2024
If you’ve ever tried to use a CSS transition on an element with the hidden
attribute or display: none;
, you know this can be a challenge. I’ve run into this problem a number of times and decided to write an npm package to provide a reusable solution.
The Problem
There are a ton of ways to hide HTML elements. You can use visibility, opacity, transforms, positioning, or even clip-paths. However, sometimes these techniques don’t work how we expect. visibility: hidden
will display empty gaps where elements would normally display, and other options may not hide elements from screen readers. Footnote
1
You can use the hidden
attribute or display: none;
to avoid these issues. But there’s one major downside to hiding elements this way: you can’t transition their properties when you’d like to show them. How can we work around this in an accessible way? I set out to write an npm package to handle this, and ended up learning a lot about document flow, transition events, and more along the way!
Showing and Hiding a Drawer
Imagine we have the following .Drawer
element. It’s hidden offscreen with a CSS transform
but also removed from the document flow with the hidden
attribute.
(All of my examples will show hiding elements with the hidden
attribute, but the same principles apply to display: none;
. The npm package supports both options.)
<div class="Drawer" hidden>Hello World!</div>
Code language: HTML, XML (xml)
.Drawer {
transform: translateX(100%);
transition: transform ease-out 0.3s;
}
.Drawer.is-open {
transform: translateX(0);
}
Code language: CSS (css)
How can we slide this element in and out? This is the end result I’d like to achieve:
Showing Our Drawer
Hidden elements can not be transitioned since they’re not in the document flow. However, we can get around this by forcing the document to reflow after removing the hidden
attribute. Then the element will be in the document flow and we can transition its CSS properties. We can use some JavaScript to accomplish this.
const drawer = document.querySelector('.Drawer');
function show() {
drawer.removeAttribute('hidden');
/**
* Force a browser re-paint so the browser will realize the
* element is no longer `hidden` and allow transitions.
*/
const reflow = element.offsetHeight;
// Trigger our CSS transition
drawer.classList.add('is-open');
}
Code language: JavaScript (javascript)
By asking the browser to think about the element’s dimensions for a moment, we trigger a reflow after removing hidden
but before adding our transition class. This gives us the best of both worlds: removing hidden content from the accessibility tree and animating it in when we want to show it.
Hiding Our Drawer
Now we know how to show our drawer, but how can we hide it again? We’ll want to be sure to re-apply hidden
, but if we do that immediately, we won’t be able to transition the drawer out. Instead, we need to wait for our transition to complete first. There are a couple of ways we can do this: transitionend
and setTimeout
.
transitionend
Event
The transitionend
Event
When a CSS transition ends it will fire a transitionend
event. In our scenario, removing the is-open
class from our drawer will automatically trigger a transition. By hooking into this, we can remove the hidden
attribute after our transition is complete.
However, we’ll want to be sure to remove our event listener after we’re done, or else it will trigger when we transition the element back in. In order to do that we can store the listener as a variable and remove it when we’re done:
const listener = () => {
element.setAttribute('hidden', true);
element.removeEventListener('transitionend', listener);
};
function hide() {
drawer.addEventListener('transitionend', listener);
drawer.classList.remove('is-open');
}
Code language: JavaScript (javascript)
We’ll also want to be sure to manually remove the listener if we start showing the element again before our hide transition has completed:
function show() {
element.removeEventListener('transitionend', listener);
/* ... */
}
Code language: JavaScript (javascript)
transitionend
and Event Bubbling
transitionend
and Event Bubbling
When using transitionend
there’s an edge case we need to be aware of. In the DOM, events bubble up from child elements to their ancestor elements. If our drawer contains elements with CSS transitions, this can cause problems.
If a child element’s transition is triggered and ends during our drawer’s transition, the transitionend
event will bubble up from the child element to our drawer, and trigger the event listener on the drawer prematurely.
To avoid this, we need to make sure the target of the transitionend
is the element we’re transitioning. We can do so by making a change to our event listener:
const listener = e => {
if(e.target === drawer) { // Match our event target to our drawer
element.setAttribute('hidden', true);
element.removeEventListener('transitionend', listener);
}
};
Code language: JavaScript (javascript)
setTimeout
Waiting for a setTimeout
Using transitionEnd
works great when the only element transitioning is the element we’re showing and hiding. However, I ran into problems using this technique recently when designing a staggered animation:
I wanted showing and hiding a hidden menu to trigger transitions on its child links. I needed to wait for all of those transitions to complete before adding hidden
to the drawer.
I initially tried to handle this by adding options to force the module to wait until a list of elements had all triggered transitionend
events. However, if a user quickly toggled the drawer they could end up in a state where some of the staggered transitions never occurred, leading to them never triggering transitionend
events.
To resolve this I waited for a timeout
instead of listening for transitionend
events:
function hide() {
const timeoutDuration = 200;
const timeout = setTimeout(() => {
element.setAttribute('hidden', true);
}, timeoutDuration);
element.classList.remove(visibleClass);
}
Code language: JavaScript (javascript)
I also needed to manually clear the timeout if the element is shown again before the transition’s completed:
function show() {
if (this.timeout) {
clearTimeout(this.timeout);
}
/* ... */
}
Code language: JavaScript (javascript)
In the end, I built my package so you could use it with either timeout
s or transitionend
events depending on your needs.
Using the npm Package
As you can see, transitioning hidden elements is more complicated than it sounds! My npm package wraps this all up into a transitionHiddenElement
module that you can use like so:
import { transitionHiddenElement } from '@cloudfour/transition-hidden-element';
const drawerTransitioner = transitionHiddenElement({
element: document.querySelector('.Drawer'),
visibleClass: 'is-open',
});
drawerTransitioner.show();
drawerTransitioner.hide();
drawerTransitioner.toggle();
Code language: JavaScript (javascript)
I hope this package is helpful in your projects. You can grab it from npm or check it out on GitHub!
Footnotes
-
The initial version of this article incorrectly stated that
visibility: hidden;
didn’t remove elements from the accessibility tree. Šime Vidas kindly pointed out that this was incorrect on Twitter. This article has been updated to correct this. Return to the text before footnote 1