CSS can't natively animate transitions that use display: none
. You can hack around this limitation by using a mix of visibility: hidden
and height: 0
to make it "close enough." While these solutions are probably fine in most cases, it isn't quite the same as using display: none
.
This post will show you a method for combining display: none
with CSS transitions that trigger the display: none
CSS property using JavaScript.
What we're building
We'll build a box that transitions from opacity: 1
to opacity: 0
when a button is clicked, then when the transition is complete, we'll toggle from the initial display property to display: none
using JavaScript. Here's what the final result will look like:
The code
Below is the code to implement the animated transition seen above:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link href="/src/app.css" />
<script src="/src/app.js" defer></script>
</head>
<body>
<div id="box" class="box"></div>
<div>
<button id="toggler">Toggle visibility</button>
</div>
</body>
</html>
/** app.css */
.box {
opacity: 1;
height: 100px;
width: 100px;
background: lightblue;
margin-bottom: 20px;
transition: opacity 1s;
}
.box--hidden {
opacity: 0;
}
/** app.js */
const toggler = document.getElementById("toggler");
const toggleBox = document.getElementById("box");
const isHidden = () => toggleBox.classList.contains("box--hidden");
toggleBox.addEventListener("transitionend", function () {
if (isHidden()) {
toggleBox.style.display = "none";
}
});
toggler.addEventListener("click", function () {
if (isHidden()) {
toggleBox.style.removeProperty("display");
setTimeout(() => toggleBox.classList.remove("box--hidden"), 0);
} else {
toggleBox.classList.add("box--hidden");
}
});
How it works
Our code toggles the CSS class .box--hidden
when the toggle button is clicked, which sets the box's opacity to 0. The .box
class has a transition
property that will animate the transition between states.
/** app.css */
.box {
opacity: 1;
height: 100px;
width: 100px;
background: lightblue;
margin-bottom: 20px;
transition: opacity 1s;
}
.box--hidden {
opacity: 0;
}
Neither the .box
class nor the .box--hidden
class have a display
property: this property will be set within JavaScript.
Our script includes a callback that executes when the transitionend
event is fired on the box. If the box includes the .box--hidden
class, it will set the box's CSS to display: none
, hiding the box once the transition animation is complete.
toggleBox.addEventListener("transitionend", function () {
if (isHidden()) {
toggleBox.style.display = "none";
}
});
On the click handler that fires at the end of the transition, it will check to see if the box is currently hidden. If it is hidden, it will removed the display: none
style applied by the previously mentioned callback, then it will set a zero-second timeout before removing the box--hidden
class. Without the zero-second timeout, the browser will render the box immediately with no transition. While it's not important to understand all of the reasons behind this, just know that it is not a race condition, but instead has to do with the browser being single-threaded, meaning that the browser must first have a chance to render the updates.
Conversely, if the box does not have the .box--hidden
class, the callback will apply it.
toggler.addEventListener("click", function () {
if (isHidden()) {
toggleBox.style.removeProperty("display");
setTimeout(() => toggleBox.classList.remove("box--hidden"), 0);
} else {
toggleBox.classList.add("box--hidden");
}
});
Recommendation: use a library instead
If you're reading this and thinking that the code looks fragile: I agree with you. The HTML, CSS and JS are tightly-coupled, and if you needed to update a class name you'd need to change it in all three files.
The animation can also break in interesting ways. For example, if you have a zero-second transition, the transitionend
event will never fire, which means display: none
will never be applied.
Instead of hand-wiring these animations, consider using a library that makes animations practical. jQuery's .fadeToggle()
method creates a comparable transition to the one we implemented in this post using a single line of code. Alpine.js and Vue let you apply different CSS classes for each stage of a transition animation. In many front-end frameworks, you can completely remove elements from the DOM after an animation ends rather than relying on display: none
to hide it.
While reducing the number of dependencies within a project is a worthy endeavor, sometimes their conveniences make them well worth including.
Top comments (4)
Thanks, Tyler: this was exactly what I needed. Shame this isn't possible in pure CSS.
I'm glad this helped! I sure wish that this worked with pure CSS too.
To help the case of 0-second transitions not firing
transitiionend
, set yourtransition-duration
to something like 0.001 to ensure the event fires, but is visual imperceivable.You can simplify JS and get rid of setTimeout by using an animation (does not require JS to fade in, requires animationend callback to fade out).