Transitioning Hidden Elements

Programming - Apr 19, 2024

Illustration of a cyclops with its eyes closed, surrounded by moving arrows.

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.

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!

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:

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.

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.

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)

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)

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 timeouts or transitionend events depending on your needs.

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

  1. 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
Previous Next
Copyrights
We respect the property rights of others, and are always careful not to infringe on their rights, so authors and publishing houses have the right to demand that an article or book download link be removed from the site. If you find an article or book of yours and do not agree to the posting of a download link, or you have a suggestion or complaint, write to us through the Contact Us .
Read More