9 CSS Dropdown Menu Animation—No Library, Pure CSS
A css dropdown menu animation is the motion a panel plays between hidden and open — height, opacity, transform, or clip-path shifting on a timer so the
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 height:auto transition
- 02 Fade + slide
- 03 Scale from corner origin
- 04 Clip-path open
- 05 Staggered items
- 06 Mega-menu background fill
- 07 Hover-intent delay open
- 08 Native popover attribute
- 09 Mobile full sheet
The nine below move from safe defaults toward browser-native shortcuts. 01 through 05 rebuild what most menu libraries already ship — a height trick, a fade, a scale, a clip, a stagger — using nothing but transition and animation. 06 through 08 lean on less-common CSS: a background that spreads sideways, a delay tuned to filter out a passing cursor, and the popover attribute standing in for a JS toggle. 09 swaps the whole pattern out for a mobile viewport. Every grid tile above opens and closes on its own inside a fixed, non-scrollable iframe — there's nothing to click there, so just watch it loop.
01height:auto transition
The panel's grid-template-rows value tweens from 0fr to 1fr instead of height jumping straight from 0 to auto, so the reveal reads as a true height:auto expansion without ever measuring the content's pixel height in JS. Reach for it on nav dropdowns or any menu that doubles as a filter accordion.
&__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
margin-top: 8px;
}
&__list {
overflow: hidden;
min-height: 0;
}
&.is-open &__panel { grid-template-rows: 1fr; }
02Fade + slide
The panel fades in while sliding down 8px from directly above the trigger, so it reads as arriving rather than appearing out of nowhere. It's the pattern most header account menus and top-nav dropdowns already reach for by default.
&__panel {
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
&.is-open &__panel {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
03Scale from corner origin
transform-origin is pinned to the corner nearest the trigger button, so the panel visibly grows out of that corner instead of scaling from its own center. It suits a top-right profile menu or a toolbar's option menu, where the trigger already sits at one edge.
&__panel {
transform-origin: top right;
transform: scale(.9);
opacity: 0;
pointer-events: none;
}
&.is-open &__panel {
opacity: 1;
transform: scale(1);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
04Clip-path open
A clip-path inset rectangle collapses the panel along its top edge, then opens back down to reveal content that was already sitting there the whole time. It fits a compact context menu inside a card or an overflow menu squeezed into a narrow toolbar.
&__panel {
clip-path: inset(0 0 100% 0);
pointer-events: none;
}
&.is-open &__panel {
clip-path: inset(0 0 0% 0);
pointer-events: auto;
transition: clip-path $duration $easing;
}
05Staggered items
Each list item carries its own transition-delay, multiplied by its position in the list, so instead of the whole panel appearing at once the items step in from top to bottom. It reads best in a settings menu or filter dropdown with enough rows for the stagger to actually register.
&__panel li { opacity: 0; transform: translateY(-6px); }
&.is-open &__panel li {
opacity: 1;
transform: none;
transition: opacity $duration $easing, transform $duration $easing;
}
@for $i from 1 through 4 {
&.is-open &__panel li:nth-child(#{$i}) {
transition-delay: ($i - 1) * $stagger;
}
}
06Mega-menu background fill
A background panel scales from 0 along the x-axis while the grid of links sitting on top of it fades in slightly after, so the fill visibly spreads sideways before the categories underneath become readable. Reach for it on an e-commerce header where one mega-menu needs to lay out several product categories at once.
&__fill {
transform: scaleX(0);
transform-origin: left;
}
&__grid { opacity: 0; }
&.is-open &__fill {
transform: scaleX(1);
transition: transform $duration $easing;
}
&.is-open &__grid {
opacity: 1;
transition: opacity $duration $easing $duration * .4;
}
07Hover-intent delay open
transition-delay holds the panel closed for 150ms after the pointer enters, so a cursor passing over the trigger on its way somewhere else never fires it open — only a pointer that actually lingers does. It matters most when several of these menus sit side by side in one header, where an accidental open next to the one someone wanted gets confusing fast.
&__panel {
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing;
}
&:hover &__panel,
&.is-open &__panel {
opacity: 1;
transform: none;
transition-delay: $delay;
}
08Native popover attribute
The trigger points at the panel with popovertarget instead of a JS click handler, and the browser itself owns opening, closing on an outside click, Escape, and focus — the ::backdrop pseudo-element even gets a dim layer for free with no extra markup. Popover reached Chrome 114, Safari 17, and Firefox 125, so pair it with a checked fallback if the menu has to work on anything older.
<button class="menu__trigger" type="button" popovertarget="menu-panel">
Options
</button>
<ul class="menu__panel" id="menu-panel" popover>
<li><a href="#">Share</a></li>
<li><a href="#">Bookmark</a></li>
<li><a href="#">Report</a></li>
</ul>
09Mobile full sheet
Below a set breakpoint the same trigger opens a full-width sheet pinned to the bottom of the viewport and sliding up with translateY, instead of the corner-anchored panel every other pattern here uses. It's built for mobile web navigation and the overflow menu in a responsive header, where a small anchored dropdown is too easy to miss with a thumb.
.menu__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(100%);
}
.menu__sheet.is-open {
transform: translateY(0);
transition: transform $duration $easing;
}
@media (max-width: 360px) {
.menu__sheet { padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
}
Where it breaks — the traps
Two of these nine assume something the browser has to actually provide, and both fail quietly instead of throwing an error. Skip the overflow: hidden rule inside item 01's list wrapper, and grid-template-rows: 0fr won't clip the content sitting underneath it, so the menu jumps straight to full height instead of tweening — the grid trick depends on that one line every bit as much as it depends on the 0fr-to-1fr pair itself. A newer property named interpolate-size would let height:auto transition directly with no grid workaround at all, but it currently ships only in Chrome, as of September 2026, so the grid trick above stays the version that works everywhere today. Item 08 has the opposite problem: popover is fast and native, but support only reaches back to Chrome 114, Safari 17, and Firefox 125, and a menu built on nothing but popovertarget silently fails to open on anything older — the button just sits there doing nothing, with no console warning to explain why. Grab every fallback already wired into the demos above rather than rebuilding nine of these checks by hand; the archive unlocks with wncdnnc7 typed exactly as it sits in this sentence, no extra spaces added.
Accessibility
Every one of the nine responds to prefers-reduced-motion the same basic way: transition and animation both collapse to none, so clicking or hovering the trigger still opens and closes the panel, just in a single frame instead of over 150 to 300ms. Nothing here depends on motion to communicate state — the open panel is still visually present and its items stay in the DOM and focusable whether or not anything moved to get there. Item 08 has the least to turn off, since popover's own outside-click, Escape, and focus handling live in the browser rather than in CSS, and reduced motion only touches the demo loop's own scale-and-fade layered on top of it. MDN documents prefers-reduced-motion and the operating-system settings that flip the query on.
@media (prefers-reduced-motion: reduce) {
.menu__panel,
.menu__fill,
.menu__grid,
.menu__sheet {
transition: none !important;
}
}
Nine more click-triggered patterns sit inside the CSS section of the archive, and the line between what actually ships in the zip and what's just described on this page is drawn on the about page.
FAQ
Does a css dropdown menu animation still need JavaScript at all?
Only for the parts a browser doesn't yet handle natively. Items 01 through 07 and 09 toggle one class on click, so the JS stays limited to a single event listener no matter how the panel itself animates. Item 08 removes even that much — popovertarget and the popover attribute hand opening, closing, and outside-click handling straight to the browser.
Why does my dropdown menu snap open instead of animating smoothly?
The usual cause is animating height directly, which CSS still can't tween the way it tweens opacity or transform. Swap plain height for the grid-template-rows trick from item 01, and check that the element wrapping the content has overflow: hidden set — without it the grid trick has nothing to clip, so the panel jumps to full size in one frame instead of expanding.
Can these patterns open on click instead of hover?
Yes, and most of the demos above already default to click. Swap the :hover selector in item 07 for a class toggled by a click handler, or drop the hover-intent delay entirely — the transition and keyframes driving each open state don't care which event actually set the class or attribute that triggers them.