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:
- The JS was removing the node. No. The element was still in the DOM after closing, just not visible.
- Some other state was applying
display: none. Also no. The drawer still had a box and still occupied its place in layout. - Another handler was stripping the class too early. I tested this by toggling the class by hand in devtools, with no JS involved at all. Same result: add the class, the animation plays; remove the class, instant disappearance.
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:
- When opening, the
.is-openrule wins and its delay is0s. The drawer becomes visible immediately, so the entrance animation is on screen from the first frame. - When closing, the class comes off and the base rule takes over, delay and all. For those 760 milliseconds the drawer is still
visible, so the panel gets to slide out and the whole reverse choreography actually plays. Only after that doeshiddenkick in.
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
- If the open animation plays but the close animation does not, suspect a discrete property in the base state first. Your transitions may be perfectly fine, just running on an element that is no longer painted.
visibilitycannot be animated gradually, but it can be delayed.transition: visibility 0s linear <duration>is how you keep an element around until its exit animation is done.- That delay belongs to the closing direction only. The open state has to override it with
0sso the entrance does not lose its opening frames. - Toggle the class by hand in devtools before you blame JavaScript. If it still misbehaves without any JS, you have just cut your search space in half.