9 CSS Hamburger Menu Animations, No Library
A css hamburger menu animation is the motion a three-bar icon plays as a menu opens, built from transform and opacity rather than a JavaScript library.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Bars morph to X
- 02 Spin into X
- 03 Morph to arrow
- 04 Middle bar slides out
- 05 Fullscreen overlay menu
- 06 Popover API menu, zero JS
- 07 Staggered link reveal
- 08 Circle reveal from the icon
- 09 Backdrop blur and dim
01 through 04 are the same three bars, only the geometry of how they meet changes — a plain X, a spin, an arrow, and a version that clears the middle bar first before the other two cross. 05 and 06 change what shows up behind the icon instead: a fullscreen panel that slides up, and a browser-native popover that needs no menu logic written in CSS at all. 07 through 09 layer motion onto whichever shape you picked — links that arrive one after another, a reveal shaped like the icon's own tap point, and a background that blurs to hand focus to the menu. Watch all nine loop in the grid above; every code block below keeps only the lines that make each one move, and the untrimmed files are in the zip.
01Bars morph to X
The top and bottom bars rotate 45 degrees into an X while the middle bar fades out beneath them, the whole move riding on transform and opacity over 300ms. It's the default hamburger button for a header — the shape most people expect before they've even read the icon.
.bar {
position: absolute; left: 50%; top: 50%;
width: 24px; height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
02Spin into X
The whole icon spins a full 180 degrees while the bars fold into the same X as item 01, so the rotation reads as one continuous gesture rather than two separate moves stacked on top of each other. It suits mobile navigation that wants opening the menu to feel more deliberate than a plain fade.
.burger {
transform: rotate(0deg);
transition: transform $duration $easing;
}
.stage.is-open .burger { transform: rotate(180deg); }
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
03Morph to arrow
The three bars narrow and tilt until they read as a left-pointing arrow instead of an X, using the same rotate approach as item 01 but with a shorter travel distance and each bar's transform-origin pinned to its far end. It fits a menu button that doubles as a back action, since the icon itself hints at what tapping it will do.
.bar {
position: absolute; right: 50%; top: 50%;
transform-origin: right center;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(50%, -50%) translateY(-7px); }
.bar2 { transform: translate(50%, -50%) scaleX(1); }
.bar3 { transform: translate(50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); }
.stage.is-open .bar3 { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }
04Middle bar slides out
The middle bar slides sideways and fades out before the top and bottom bars rotate into their X, so the three bars never cross paths mid-transition the way they do in item 01. It works best in wide headers with room to spare, where that extra half-step has space to actually read.
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
05Fullscreen overlay menu
A full-screen color panel rises from the bottom over 600ms via transform: translateY(), covering the page as the bars behind it collapse into an X. It's built for mobile navigation carrying more links than a small dropdown can hold without feeling cramped.
.panel {
position: absolute; inset: 0;
transform: translateY(100%);
transition: transform $duration $easing;
}
.stage.is-open .panel { transform: translateY(0); }
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
06Popover API menu, zero JS
The menu opens with nothing but the popover attribute on the panel and popovertarget on the button, so the open state, the light dismiss on an outside click, and the top-layer stacking all come from the browser instead of a script. That makes it a good fit for static sites that need a working menu without shipping a JavaScript build step at all.
<button class="burger" popovertarget="menu" type="button" aria-label="Open menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<div id="menu" class="menu" popover aria-label="Menu">
<a class="menu__link" href="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Contact</a>
</div>
07Staggered link reveal
Once the panel is open, each link fades in roughly 30ms after the one above it, using a single custom property multiplied into transition-delay instead of a loop of setTimeout calls. It reads well on dropdown menus with four to six links, where an instant reveal feels flat and a full stagger animation feels like overkill.
.menu__link {
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $ease-spring, transform $duration $ease-spring;
transition-delay: calc(var(--i) * #{$stagger});
}
.stage.is-open .menu { opacity: 1; pointer-events: auto; }
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }
08Circle reveal from the icon
A circle grows outward from the icon's own position until it covers the screen, driven by animating a single clip-path: circle() value rather than swapping in a whole new stacking layer. It suits an icon button next to a logo, or a landing page that wants the reveal itself to carry some visual weight.
.reveal {
position: absolute; inset: 0;
clip-path: circle(0% at calc(100% - 30px) 30px);
transition: clip-path $duration $easing;
}
.stage.is-open .reveal {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
09Backdrop blur and dim
The page behind the menu blurs by 6px and dims by about 40 percent as the panel opens, both driven by a single filter transition on the content itself rather than a separate overlay element. It's built for content-heavy pages, where an overlay menu needs to visually pull away from everything still readable underneath it.
.page {
transition: filter $dur-base $ease-spring;
}
.stage.is-open .page { filter: blur($blur) brightness(1 - $dim); }
.menu {
opacity: 0;
transform: translateY(-8px) scale(.96);
transition: opacity $dur-base $ease-spring, transform $dur-base $ease-spring;
}
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); }
Where it breaks — the traps
08's reveal is a single animating clip-path, and support for clip-path itself is safe in every modern browser today, as MDN's clip-path reference confirms. 09 could have reached for backdrop-filter on a floating overlay instead of filtering .page directly, but backdrop-filter only ships unprefixed in Safari from version 18, alongside Chrome 76+ and Firefox 103+, per MDN's backdrop-filter browser compatibility table — filtering the actual content node sidesteps that gap, which is the real reason the demo does it that way rather than layering a blurred sheet on top.
The second trap is item 06's popover: popovertarget and the popover attribute hand you the open state, the outside-click dismiss, and the top-layer stacking for free, but they don't hand you a transition unless you also style the :popover-open selector — skip that, and the panel still works — it just snaps open with no motion at all, which looks like a bug rather than a missing style. The zip's password is 2cpj9dyd, sitting in this paragraph the same plain way it sits folded into the SCSS file itself, easy to miss if you're skimming for a code block instead of reading the sentence.
The third trap is the preview above: the 3×3 grid sits inside a fixed, non-scrollable iframe, so every icon there opens and closes on its own two-second loop instead of waiting for a tap — watch it rather than trying to click any of the nine squares.
Accessibility
prefers-reduced-motion strips something different from each family here rather than one blanket "turn everything off." 01 through 05 and 08 keep every open and closed position exactly where it was; they just collapse the transition duration down to near-nothing, since a menu that can't move from closed to open at all would be broken rather than calmer. 09's blur and dim behave the same way — the page still darkens and softens, only instantly instead of over a fade. 06's popover motion collapses to the same instant swap, since the actual open and close mechanism is the popover attribute itself, not the transition riding on top of it. 07 is the one exception worth calling out on its own: the stagger's transition-delay resets to zero, so every link appears together instead of one after another, because a 30ms cascade reads as decoration once someone has asked their system to skip it. MDN's prefers-reduced-motion guide has the full browser support table.
@media (prefers-reduced-motion: reduce) {
.bar, .menu, .panel, .reveal, .page { transition-duration: 1ms; }
.menu__link { transition-duration: 1ms; transition-delay: 0ms; }
}
More CSS transition patterns live in the CSS category, and what this project actually is gets explained on the about page.
FAQ
Does a css hamburger menu animation need JavaScript?
Only to toggle a class in most of these — items 01 through 05 and 07 through 09 swap a class like is-open on click, and that single click handler is the only script involved, since the motion itself is transform, opacity, filter, and clip-path written in CSS. Item 06 needs no script at all, because popovertarget and popover handle the open state, the outside-click dismiss, and the Escape key on their own.
What's the difference between a plain overlay panel and a clip-path reveal?
An overlay like item 05's panel is a whole extra element that slides or fades into place on top of everything else, while item 08's clip-path animates the visible shape of one element that's already sitting there, growing a circle instead of moving a box across the screen. Clip-path tends to be cheaper to animate since the browser only recalculates the clipping region rather than layout, but the reveal shape is limited to whatever clip-path can describe — a circle here, anchored to the icon's own corner.
Can these run inside a React or Vue component?
Yes. Every effect here is scoped to class names and CSS custom properties, so a component only needs to add or remove is-open on click; nothing in the SCSS reaches into the DOM beyond what a framework already renders for you. The zip's react/ folder has a working version of each item as a small component, in case you'd rather start from that than wire up the class toggle by hand.