Animate a hidden element is very simple now. We just need 2 CSS declaration and a bit of JavaScript to toggle the state open / close.
The solution
In this article:
- CSS
:not
pseudo-selector - Single Source of truth
-
animationend
event - Accessibility
Problem we try to solve
You have a hidden element with a hidden
attribute. To provide a better UX, you want to animate this opening and closing state. But in CSS, display:none
is like an interrupter; it can be "on" or "off" but animate between both state with a CSS transition is impossible.
Step 1: animate during opening
The HTML
<a href="#modal" class="modal-button">Open Modal</a>
<div class="modal" id="modal" hidden>Modal content</div>
As you notice, I'm opting for a link instead of a button. Why ? Because my modal will still be accessible without JavaScript, thanks to the target
CSS pseudo-class. I will focus on this point after.
JS to show the modal
modalButton.addEventListener("click", function (e) {
e.preventDefault();
modal.hidden = false;
});
CSS
.modal:not([hidden]) {
animation-name: popIn;
}
That's it. ¯_(ツ)_/¯.
If you prefer relying on .hidden
class (like in Tailwind), you can switch :not([hidden])
with :not(.hidden)
. If you want both, the not
pseudo-class accept multiple arguments separated by a comma : not([hidden], .hidden)
. Anyway, our Modal appears with a shiny animation now :
Step 2 : animate during closing
The closing state is a little more tricky. If you set the hidden
attribute to "true", you won't be able to hide it smoothly. You need to add a temporary class like is-closing
to play the closing animation and then, hide the element.
JS
modal.addEventListener("click", function (e) {
// Omitted…
if (hasClickedOutside || hasClickedCloseButton) {
modal.classList.add("is-closing");
// Omitted…
CSS
.modal.is-closing {
animation-name: popOut;
}
Now our modal is closing smoothly, but it is not back to hidden state. You have to wait to the end of the animation to remove the .is-closing
class and back to hidden="true"
. With setTimeout
? You could, but you have a better option.
Animationend event
With a timeout
, we have to declare a value at least equal to the animation duration, which can change.
If you can, you have to have a single source of truth : here, the animation duration declared in the CSS.
The animationend
will wait to the end of the animation, then execute the function inside the listener.
modal.addEventListener("click", function (e) {
const hasClickedOutside = !e.target.closest(".modal-main");
const hasClickedCloseButton = e.target.closest(".modal-close");
if (hasClickedOutside || hasClickedCloseButton) {
modal.classList.add("is-closing");
modal.addEventListener(
"animationend",
function () {
modal.hidden = true;
modal.classList.remove("is-closing");
},
{ once: true }
);
}
});
Once the event is completely done, you have to destroy it with the once: true
option, as you don't need it anymore.
And voilà, you have the knowledge to animate any element hidden in the DOM.
Bonus : A little accessibility enhancement
Button vs Link
As I said above, I choose a <a>
instead of a <button>
because of that :
.modal:target {
display: grid !important;
animation-name: popIn;
.modal-close {
display: none;
}
}
Without JS, the modal can still be open via its hash, and you can style the opened state with the :target
pseudo class.
To close it, the user needs to back in your history. This is why I hide the .modal-close
. It's not pertinent to show it if it can't do anything.
Don't play animation if user don't want animation.
For personal taste, medical reason or to solve a performance issue on their device, your users may not want any animation, and you have to respect their preferences. It would be a good idea to embed the following the rule as part of your CSS reset, if it's not already done.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Thanks for reading. 👋
Top comments (0)