Transitioning Hidden Elements

Written by Paul Hebert on

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.

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. 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>
.Drawer {
  transform: translateX(100%);
  transition: transform ease-out 0.3s;
}

.Drawer.is-open {
  transform: translateX(0);
}

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');
}

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.

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');
}

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);

  /* ... */
}

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

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

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

  /* ... */
}

In the end, I built my package so you could use it with either timeouts 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();

I hope this package is helpful in your projects. You can grab it from npm or check it out on GitHub!


  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. 
Paul Hebert

Paul Hebert is a hybrid designer and developer at Cloud Four. When he's not designing and developing websites he enjoys bouldering, drawing, cooking, gardening, and eating too much cheese.

Never miss an article!

Get Weekly Digests


Comments

Curious about your use case here. Positioning elements “off-canvas” has long been the way around using display: hidden on elements that should have some sort of entrance animation. Position something to the left or the top of the body and scroll-position is not affected. Or position something out of the bounds of another element with overflow: hidden on it and it is rendered but not in view.

Using display: none or the HTML equivalent hidden attribute poses an accessibility problem. If your only menu on the page, for example, had the hidden attribute on it, a screen reader would not traverse that content. Its effectively as if the page has no navigation. Further, a screen reader may not get to the trigger that shows the menu content for two reasons — lack of JS (though this is an edge case), but most of all, a lack of understanding that the menu has to be brought into view.

What made you require display: none or the hidden attribute as the basis for a solution?

Replies

Hey J. Hogue,

Great question!

The example in the article shows a drawer and menu, but we’ve used similar techniques for dialogs, accordions, multi-step wizards, etc. We generally use this for what we consider “Completely Hidden Content” as outlined in this article by Scott O’Hara but sometimes it’s tricky to decide what falls in this category.

We landed on this solution after noticing a few problems with off screen elements:

  1. Off screen elements can still be tab-focused by keyboard users. This can be very confusing. It appears as if focus has suddenly been lost, it can take several tabs to get back to visible content, and keyboard users may accidentally trigger offscreen elements. For example, try to focus the second visible button in this CodePen.
  2. Partially sighted users may become confused about why their screen reader is reading content that is not visible on the screen.
  3. Some content isn’t always relevant. Validation error messaging, error and success dialogs, etc. should be hidden until it becomes relevant to show.

How do you handle these issues while hiding content off-canvas?

Further, a screen reader may not get to the trigger that shows the menu content for two reasons — lack of JS (though this is an edge case), but most of all, a lack of understanding that the menu has to be brought into view.

I agree this is definitely a concern! We try to alleviate the understanding issue with clear button text (e.g. “View Menu”) and avoid hiding content in general, but sometimes it’s necessary. In those cases we generally try to provide screen reader users with the same experience as other users, and sometimes that requires fully hiding content instead of positioning it off-canvas.

We always do our best to build accessible sites, but in many ways it’s a continuous learning process. I found the following articles which mention avoiding display: none; and using visually-hidden classes (which we use in other situations), but they don’t mention handling the issues outlined above.

I also found this article where hidden is used when building accessible interfaces:

I’d love to learn more if you’ve got more info on this. Thanks!

Replies

Fantastic response, thanks for that. Very in-depth. We have run into similar issues where sometimes hidden is the best way to go. The off-screen tabbing in particular is the reason why we would want to use hidden. The inclusive-components.design site is new to me, so thanks much for that as well!

Replies

Sure thing J.!

Thanks for commenting and prompting me to re-examine some accessibility techniques I hadn’t reconsidered in a while! It’s always good to take some time and think these things through. Accessibility is complicated and there are lots of different scenarios that call for different approaches!

The inclusive-components.design site is great! We often use it as a starting point when building out a new component.

Cheers!
Paul

Hey Paul

Just wanted to drop a message to say thank you for a interesting and informative piece, and to give you a big 👍for the github repository, nothing like clean and well documented code.


Let’s discuss your project! Email Us