D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Drawer Close Animation Never Plays? `visibility: hidden` Snaps Back the Instant the Class Comes Off

· · 6 min read
Drawer Close Animation Never Plays? `visibility: hidden` Snaps Back the Instant the Class Comes Off

Some bugs are annoying not because they are hard to find, but because half the feature works perfectly. I was building a slide-in navigation drawer for an online store theme. The open animation was lovely: the panel glided in, a clip-path peeled open, and the contents followed with layered skew and fade steps. I was pleased with myself.

Then I hit close, and the drawer just disappeared. Not closed. Not reversed. Gone in a single frame, like someone had pulled the plug.

The symptom: half the choreography went missing

The confusing part was that every transition I had written was symmetric. There was not a single rule dedicated to the closing direction. I simply put .is-open on the drawer element and let every child react to that state. Logically, removing the same class should have reversed everything with identical timing.

It did not. Opening: smooth. Closing: blink, gone.

This was not the kind of bug that shows up in the console either. No error, no warning, no property failing to parse. The CSS was valid, the JS ran, and the result was still wrong.

My first suspects were all wrong

With no error trail to follow, I guessed my way toward JavaScript. Theories I discarded one by one:

That last one closed the case. If toggling the class manually already misbehaves, the problem lives entirely in CSS.

The root cause: visibility is a switch, not a slider

My drawer's base state looked like this, and it looks perfectly reasonable:

.drawer {
  visibility: hidden;
  pointer-events: none;
}
.drawer.is-open {
  visibility: visible;
  pointer-events: auto;
}

visibility: hidden was there so a closed drawer is genuinely out of reach: not tabbable, not announced by screen readers, not a focus trap waiting to happen. That decision was right. What was wrong was when it took effect again.

The moment .is-open came off, visibility: hidden from the base state applied on that very frame. There was no transitional period, because without an explicit transition rule, visibility flips between values discretely. There is no halfway point between visible and hidden for the browser to animate through.

And once the parent element is not visible, none of its children are either. The transform, clip-path and opacity transitions inside the drawer were still running, they were just running somewhere that never gets painted. For more than 700 milliseconds the browser was diligently animating something that had already left the screen.

So the close animation was not missing. It was cut off at frame one.

The fix: delay the visibility flip

visibility cannot be animated gradually, but it can be delayed. That is what makes the trick work: give it a transition-delay equal to your longest closing transition, with a transition duration of zero.

.drawer {
  visibility: hidden;
  pointer-events: none;
  transition: visibility 0s linear 760ms; /* match the longest reverse transition */
}
.drawer.is-open {
  visibility: visible;
  pointer-events: auto;
  transition: visibility 0s linear 0s; /* opening must be immediate */
}
.drawer__panel { transform: translateX(100%); transition: transform 720ms ease; }
.drawer.is-open .drawer__panel { transform: translateX(0); }

Read those two rules as two directions, not as one value:

Notice the asymmetry. The delay is only needed in one direction, which is exactly why these two rules cannot be collapsed into one.

Why 760ms and not 720ms

The longest transition inside my drawer was the panel transform at 720ms. I set the delay slightly higher, 760ms, to leave headroom. Match it exactly and you are playing on the boundary: a little frame rounding, or an easing curve that has not quite landed, and the tail of the animation still gets clipped.

More importantly, that number has to be tied to the longest transition inside the drawer, not to a feeling. Add an element with a 900ms transition in there later and 760 is instantly wrong again, and the bug returns in a subtler form where only the tail of the animation goes missing. If the drawer is going to grow, park the number in a CSS variable and feed both places from it.

Do not be too generous either. A delay much longer than the animation means the drawer stays visible well after it has visually finished closing, which brings back part of the accessibility problem you were avoiding in the first place.

pointer-events is deliberately not delayed

In the code above, pointer-events flips with no delay at all. That is on purpose. The instant a user hits close, the drawer should stop accepting clicks, even though it is visually still sliding away for another three quarters of a second. Include it in the delay and you get a window where a departing panel is still clickable, which reads as a different bug entirely.

So these two properties look similar but earn different treatment: visibility is held back until the animation finishes, pointer-events is released right now.

A note on the newer alternative

If transition-behavior: allow-discrete is the first thing that comes to mind, be careful. That property exists for properties whose animation behavior is genuinely discrete, like display, content-visibility and overlay. Putting it on visibility changes nothing, because visibility is already transitionable without it.

The modern route for this kind of problem is to move from visibility: hidden to display: none, then transition display with allow-discrete and add a @starting-style block so the entrance animates too. I did not need to go that far here. The delay trick is a single line, needs no particular browser support, and is easy to understand for whoever opens that file six months from now.

What I took away