The 300-Line Nightmare
I was staring at a 300-line React component at 11pm last Tuesday. All it did was animate three little horizontal lines turning into an “X”. The code had timeouts, state variables for each individual line, and a massive dependency on a third-party physics animation library.
I deleted the whole file. It felt great.
We somehow convinced ourselves over the last few years that JavaScript needs to control every pixel of motion on the screen. If you want sequential motion—like a hamburger menu where the top bar slides, then the middle fades, then the bottom rotates—the default reflex is to reach for JS. You track the open state, fire an event, and map over an array of lines to stagger their rendering.
It is exhausting. And completely unnecessary.
Modern CSS structural styling handles this natively now. It does it without blocking the main thread, without shipping extra kilobytes to the client, and without re-rendering your entire navigation tree.
Pure CSS Delay Logic

The secret to sequential motion in CSS isn’t writing a dozen different keyframe animations. It comes down to basic math and custom properties.
If you build a standard hamburger button, you usually have three empty <span> tags inside a <button>. To stagger their movements, I just assign a local CSS variable to each one in the HTML. The first span gets style="--i: 1", the second gets --i: 2", and so on.
Then, in the stylesheet, you write one single transition rule:
transition-delay: calc(var(--i) * 75ms);
That’s it. When the menu state changes, the browser automatically staggers the motion. The first line waits 75 milliseconds. The second waits 150. The third waits 225. You get a cascading, fluid animation that looks expensive but costs practically zero CPU cycles.
The Reversal Gotcha
Here is the thing nobody mentions when they post those slick CSS-only toggle animations on social media. Getting the lines to stagger on the way in is easy. But what happens when you click close?
The animation plays forward again. Line 1 reverses, then line 2, then line 3. It looks disjointed and clumsy. A physical object wouldn’t collapse like that. It should fold back up in reverse order: 3, 2, 1.

I spent an embarrassing amount of time fixing this on a client project last month. My first instinct was to write a completely separate set of closing keyframes. Bad idea. The actual fix requires you to invert the delay math on the default state, and apply the forward math on the active state.
So your default CSS (the closed state) uses a reversed calculation: calc((3 - var(--i)) * 75ms). Your open state switches back to the standard forward delay. When you toggle the button, the lines fan out top-to-bottom. When you close it, they zip back up bottom-to-top. Symmetrical staggered animations with pure math.
Real-World Quirks
I pushed this exact setup to our staging environment yesterday. I stripped out the old Framer Motion library we were using just for the nav, which dropped our bundle size by about 28kb. Tested it across the board.
On Chrome 142 and Firefox 136, it’s buttery smooth. I did hit a weird edge case in Safari 19.2, though. The middle line would randomly flicker during the crossfade when the delay kicked in. It’s an old painting glitch that Apple still hasn’t completely squashed. Dropping a quick transform: translateZ(0) on the parent container forced hardware acceleration and fixed the repaint bug instantly.
You also don’t need those terrible hidden checkbox hacks anymore to track the state. I just wire up a tiny inline script to toggle aria-expanded="true" on the button itself. This keeps the accessibility tree happy. Then I let the CSS :has() pseudo-class do the heavy lifting.
If the button has the attribute, the child spans move. If it doesn’t, they go back.
We are sending megabytes of JavaScript down the wire to do things the browser’s native rendering engine is begging to handle for free. Stop fighting the platform. Write better stylesheets.



