CSS Hover Dropdown Menu
A navigation dropdown that opens on hover using :hover and visibility.
A CSS dropdown menu is a panel positioned under a trigger and revealed when the trigger is hovered. The whole thing is two rules: position: absolute to take the panel out of the flow, and a :hover on the wrapper to switch it from hidden to visible. What makes it work smoothly is the pair of properties used to hide it, because display: none cannot be animated and opacity alone leaves an invisible panel intercepting clicks.
The hover trigger is also the pattern's weakness. Touchscreens have no hover, and a keyboard cannot produce one. Two more versions follow: one opened by a click, so it works on a phone and with a keyboard, and one with a nested submenu that flies out sideways.
Hover a menu item
HTML
<div class="dropdown">
<span class="dropdown-trigger">
Products
<svg class="dropdown-arrow" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
<div class="dropdown-menu">
<a href="#">Web Design</a>
<a href="#">Mobile Apps</a>
<a href="#">Branding</a>
</div>
</div>
CSS
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .55rem 1rem;
background: #b8ff57;
color: #0c0c0d;
border-radius: 8px;
font-size: .9rem;
font-weight: 500;
cursor: default;
user-select: none;
}
.dropdown-arrow {
flex-shrink: 0;
transition: transform .2s;
}
/* hidden but still laid out, so opacity can transition */
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 160px;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .1);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity .2s, transform .2s, visibility .2s;
}
.dropdown-menu a {
display: block;
padding: .6rem 1rem;
color: #f0f0f0;
font-size: .875rem;
text-decoration: none;
transition: background .15s;
}
.dropdown-menu a:first-child { border-radius: 8px 8px 0 0; }
.dropdown-menu a:last-child { border-radius: 0 0 8px 8px; }
.dropdown-menu a:hover {
background: rgba(184, 255, 87, 0.1);
color: #b8ff57;
}
.dropdown:hover .dropdown-arrow {
transform: rotate(180deg);
}
.dropdown:hover .dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
Other ways to build it
Opens on click, so touch and keyboard work
The trigger is a <label> bound to a hidden checkbox, so a tap or the space bar toggles the panel and it stays open until the reader closes it. The panel also opens under :focus-within, which keeps it visible while Tab moves through the links inside. There is no gap to cross: the six pixels of spacing are padding-top inside the panel rather than an offset on top, so the pointer never leaves the wrapper on the way down.
HTML
<div class="cdrop">
<input class="cdrop-check" type="checkbox" id="acct">
<label class="cdrop-trigger" for="acct">Account</label>
<div class="cdrop-menu">
<a href="#">Your profile</a>
<a href="#">Billing</a>
<a href="#">Sign out</a>
</div>
</div>
CSS
/* focusable, unlike display:none, so space toggles it */
.cdrop-check {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
/* flush against the trigger: the visual gap is padding
inside the panel, so there is no dead strip to cross */
.cdrop-menu {
position: absolute;
top: 100%;
left: 0;
padding-top: 6px;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity .2s, transform .2s, visibility .2s;
}
.cdrop-check:checked ~ .cdrop-menu,
.cdrop:focus-within .cdrop-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.cdrop-check:focus-visible + .cdrop-trigger {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
Nested submenu
A second level is the same panel positioned to the side instead of below: left: 100% and top: 0 against its own position: relative parent, which is the list item rather than the whole menu. The submenu overlaps its parent item by a few pixels so the pointer never crosses a gap on the way across. Depth beyond two levels is where hover menus become unusable, because every level adds another strip of nothing to traverse without slipping off.
HTML
<div class="sub-holder">
<div class="dropdown sdrop">
<span class="dropdown-trigger">
Products
<svg class="dropdown-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
<div class="dropdown-menu">
<a href="#">Web Design</a>
<div class="sdrop-item">
<a href="#" class="sdrop-parent">Mobile Apps
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<div class="sdrop-menu">
<a href="#">iOS</a>
<a href="#">Android</a>
<a href="#">React Native</a>
</div>
</div>
<a href="#">Branding</a>
</div>
</div>
</div>
CSS
/* the containing block for the flyout is the item,
not the whole menu */
.sdrop-item {
position: relative;
}
.sdrop-menu {
position: absolute;
left: 100%;
top: -4px;
/* a few pixels of overlap, so the pointer never
crosses a gap on the way across */
margin-left: -4px;
min-width: 150px;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 8px;
opacity: 0;
visibility: hidden;
transform: translateX(-6px);
transition: opacity .18s, transform .18s, visibility .18s;
}
.sdrop-item:hover .sdrop-menu,
.sdrop-item:focus-within .sdrop-menu {
opacity: 1;
visibility: visible;
transform: translateX(0);
}
How it works
visibility:hidden together with opacity:0 hides the menu while keeping it in the layout flow so it can be transitioned. opacity alone cannot be transitioned to or from display:none. Using visibility allows a transition on opacity, and visibility then jumps to hidden once the fade completes. The parent :hover selector reveals the child menu.
visibility and opacity do different jobs and the pattern needs both. opacity: 0 makes the panel invisible but leaves it in place, still catching clicks over whatever is underneath it. visibility: hidden removes it from hit testing but is not a smoothly animated property. Transitioning both together gives the useful combination: the opacity fades over 200ms, and visibility flips at the end of that window rather than at the start, because a transition on visibility delays the jump to the end of the duration. The panel fades out and only then stops accepting the pointer.
The gap between the trigger and the panel is the classic bug in this pattern. top: calc(100% + 6px) puts six pixels of nothing between them, and while the pointer crosses that strip it is over neither the trigger nor the panel, so :hover stops matching and the panel begins to fade. Moving quickly hides the problem. Moving slowly makes the menu flicker shut halfway to the item the reader wanted. Two fixes work: keep the panel flush at top: 100% and create the visual gap with padding-top inside the panel, or add a transparent ::before on the panel that spans the gap and bridges the two.
Hover propagates up the DOM, not up the box tree. The panel is a child of the wrapper even though it is painted outside the wrapper's box, so .dropdown:hover stays true while the pointer is anywhere over the panel. This is what lets one :hover on the wrapper control both elements, and it is why the panel must be nested inside the trigger's wrapper rather than being a sibling positioned to look attached.
A dropdown that must work on touch needs a different trigger. The click version below uses a checkbox and a label, the same state-in-a-form-control technique as show hide content. The one thing it cannot do is close when the reader clicks elsewhere on the page, because CSS has no concept of an outside click. The workaround is a full screen transparent <label> behind the panel, which is exactly how the backdrop on off canvas navigation is built.
z-index needs watching once there is more than one dropdown. The panel has to sit above the page content, but a wrapper with transform, filter or opacity on an ancestor creates a stacking context that traps the panel inside it, so a panel with z-index: 100 can still be painted under a neighbouring card. If a dropdown disappears behind something it should cover, look at the ancestors rather than raising the number.
CSS properties used
positionrelativeon the wrapper establishes the containing block.absoluteon the panel positions it against that wrapper rather than the page.visibilityhiddenstops the panel receiving pointer events. Transitioned alongsideopacityso the flip happens at the end of the fade rather than the start.opacity- The animated part of the reveal. On its own it leaves an invisible panel still swallowing clicks.
transformtranslateY()gives the panel a small drop as it appears. A transform costs no layout, unlike animatingtop.:hover- Applies to the wrapper while the pointer is over the trigger or anywhere in the panel, because hover propagates up the DOM tree.
:focus-within- The keyboard equivalent. Keeps the panel open while focus is on any link inside it, which
:hovercannot do.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:hover | 4 | 2 | 3.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
:focus-within | 60 | 52 | 10.1 | 79 |
:checked and ~ | 4 | 3.5 | 3.2 | 12 |
Figures come from Can I Use for CSS 2.1 selectors, CSS transitions, 2D transforms, the :focus-within pseudo-class, and CSS3 selectors. :focus-within is the only modern requirement, and it is worth adding even where the floor matters, because without it a hover menu is unreachable by keyboard. Where it is missing, a plain :focus on the trigger opens the panel but closes it again as soon as focus moves onto the first link inside.
Accessibility notes
A hover-only menu is unusable on a phone and unusable with a keyboard, and roughly half of all web traffic is touch. Mobile browsers synthesise a hover on first tap, so the panel opens and then usually closes again on the next tap anywhere. Add :focus-within alongside :hover at minimum, so Tab opens the menu and it stays open while the reader moves through the links inside it.
The trigger should be a real control. A <span> styled to look like a menu button is not focusable and announces as nothing. Use a <button> for the hover and focus version, or a <label> bound to a checkbox for the click version, which is focusable through the input behind it.
CSS alone cannot report the open state. aria-expanded has to change value as the menu opens and closes, and nothing in a stylesheet can write an attribute. A screen reader therefore hears a button or a checkbox with no indication that a menu appeared. For site navigation this is usually acceptable, because the links are announced when focus reaches them. For a menu of actions, where the reader needs to know a panel opened before deciding to enter it, use script.
Escape should close an open menu. That is a keyboard convention with no CSS equivalent. The click version at least lets a second click on the trigger close it, which is better than a hover menu that only closes when the pointer leaves.
What you can build with it
- Site navigation. Top level sections with their children one level down, which is the case this pattern was invented for.
- Account menus. Profile, settings and sign out under an avatar, where the click version is the right trigger because these are actions rather than navigation.
- Filter and sort controls. A short list of options under a button on a listing page, with the current choice shown on the trigger itself.
- Language switchers. A handful of links that would take too much room as a permanent row.
- Table row actions. A menu per row, where the stacking context warning matters most, because table cells and transformed wrappers are common places for a panel to get trapped.
Mistakes worth avoiding
- Leaving a pixel gap between the trigger and the panel. The pointer crosses dead space on the way down,
:hoverstops matching, and the menu flickers shut. Move the gap inside the panel as padding, or bridge it with a transparent pseudo-element. - Hiding the panel with
opacity: 0alone. It is invisible and still on top of the page, so it silently eats clicks on whatever is underneath it. - Hiding the panel with
display: none. Nothing about that can be transitioned, so the menu appears instantly no matter what duration is set. - Building the trigger from a
<span>or a<div>. It cannot be focused, so the menu never opens for anyone using a keyboard, and no amount of CSS fixes that. - Raising
z-indexto fix a panel that is painted underneath something. If an ancestor has atransform, afilteror anopacitybelow 1, the panel is trapped in that stacking context and the number has no effect.
Frequently asked questions
How do I make a dropdown menu with CSS only?
position: relative, the panel position: absolute with top: 100%, and hide the panel with opacity: 0 plus visibility: hidden. Then .wrapper:hover .panel { opacity: 1; visibility: visible } reveals it. Add :focus-within next to :hover so it also works from a keyboard.Why does my dropdown flicker when I move the mouse to it?
top: 100% and create the visual spacing with padding inside the panel, or add a transparent ::before on the panel that covers the gap.Do CSS dropdowns work on mobile?
How do I close a CSS dropdown when clicking outside it?
<label> bound to the same checkbox, sitting behind the panel and above the page. Clicking anywhere outside the panel then hits the label, which unchecks the box. The same technique is what makes the backdrop on an off canvas panel work.Why is my dropdown appearing behind other content?
transform, filter, opacity below 1, or will-change confines the panel's z-index to that subtree, so it can never rise above a sibling of the ancestor. Remove the property from the ancestor, or move the panel out of it.