How to Build a CSS Dropdown Menu With HTML and CSS
No script is involved at this stage. The menu is a nested list of links that stays hidden until a selector reveals it. It sits in the same family as dropdown UI components, though it handles site navigation instead of form input.
The order matters a little, since each step leans on the one before it.
- Wrap the menu in a
nav element with an aria-label. The nav element is already a navigation landmark, and the label gives it a name so assistive technology can tell it apart from other navigation regions on the page. - Nest a second
ul inside the li that owns the submenu. A submenu placed outside its parent item breaks the hover relationship. - Give the parent item
position: relative, and give the nested list position: absolute and display: none. Skip the relative parent and the submenu anchors to the nearest positioned ancestor, or to the page, instead of its trigger. - Reveal the submenu with
:hover and :focus-within on the parent item. Hover alone locks keyboard users out.
The minimal markup and CSS look like this.
<nav aria-label="Main">
<ul class="menu">
<li class="has-sub">
<a href="/services">Services</a>
<ul class="sub">
<li><a href="/web">Web design</a></li>
<li><a href="/support">Support</a></li>
</ul>
</li>
</ul>
</nav>
.has-sub { position: relative; }
.sub { display: none; position: absolute; top: 100%; left: 0; }
.has-sub:hover > .sub,
.has-sub:focus-within > .sub { display: block; }
Plain HTML carries the structure, so every link stays reachable if the stylesheet fails to load.
That baseline reacts to hover and focus and nothing else. Touch support and closing the menu (Escape, outside clicks) take extra work, covered further down.
Which Trigger Method Fits: Hover, Focus-Within, Details and Summary, Checkbox or Popover?
For shallow site navigation I’d pick :hover plus :focus-within. It’s the simplest option that works for pointer and keyboard users both. Details and summary suits menus that open on click, and the checkbox hack mostly survives in legacy projects.
| Method | Opens on | Keyboard and touch | Main failure |
|---|
| :hover alone | Pointer over parent | No keyboard, unreliable tap | Submenu never appears for Tab users |
| :hover plus :focus-within | Pointer or Tab focus | Keyboard yes, touch partial | No Escape to close |
| details and summary | Click, Enter, Space | Both work | No close on outside click |
| Checkbox hack | Click on a label | Touch works, keyboard weak | Wrong semantics for a menu |
| Popover API | Button click, light dismiss | Both work | Newer API, support check needed |
Browser support isn’t a worry for :focus-within. MDN lists it as Baseline Widely available, with support across browsers since January 2020.
Hover plus focus-within needs no script. The whole thing comes down to two selectors in one rule.
Details and summary toggles on click, Enter and Space, again with no script, and its content can be positioned like any other submenu.
The checkbox hack flips a menu through :checked on a hidden input, and it fails whichever way you hide that input. With display: none keyboard users cannot reach it. Hide it visually instead and screen readers announce a checkbox, not a menu.
Giving several details elements the same name attribute makes them mutually exclusive, so opening one closes the others. MDN marks that attribute Baseline Newly available since September 2024, and the WebDX web-features dataset lists Chrome 120, Safari 17.2 and Firefox 130 as the first versions with it.
Escape-to-close is the gap in the hover-plus-focus option, and a few lines of JavaScript fill it.
How to Show, Hide and Animate the Submenu
When the submenu needs a fade, use visibility: hidden with opacity: 0. When it doesn’t, display: none is fine. Both take the links out of the tab order, which is why the parent item has to open the menu on focus before a keyboard user tabs in.
display and visibility compared
display: none removes the box from layout. It’s the easiest to debug, but older engines can’t animate it.
visibility: hidden plus opacity: 0 keeps the box in layout, so opacity and transform can transition. Pair it with a delayed visibility change, such as transition: opacity 0.2s, visibility 0s linear 0.2s, so the menu fades out before it disappears.
Offscreen positioning parks the submenu at something like left: -9999px. The links stay in the tab order and the accessibility tree, which helps screen reader users and surprises sighted keyboard users until :focus-within reveals the menu.
Visibility plus opacity is the default choice. Offscreen positioning only earns its place when screen readers must read the links while the menu is closed.
Reset the visibility delay to 0s in the open rule, or the menu fades in invisible. That one catches people out.
Animating the open and close
transition-behavior shipped in Chrome 117, Safari 17.4 and Firefox 129, which puts display inside a normal transition for the first time. MDN still lists Firefox as partial, because it does not transition display yet.
Set transition-behavior: allow-discrete on the submenu and write the entry state inside @starting-style. Browsers without support skip the animation and snap, which is an acceptable fallback for a menu.
Animate opacity and transform only. Height and top trigger layout on every frame.
Closing the hover gap with a delay or overlap
Any gap between the trigger and the submenu closes a hover menu the moment the pointer crosses it.
The pointer leaves the parent item, the :hover state ends, and the menu disappears before the cursor arrives.
- Set
top: 100% with no margin on the submenu, and use padding inside it instead of a margin so the space stays hoverable - Add a transparent
::before bridge on the parent when the visual gap is intentional
Baymard Institute recommends a hover delay of 300 to 500 ms before a hover menu opens, and its e-commerce UX benchmark found that 60% of sites skip it. Nielsen Norman Group adds the closing side: keep hover content open until the pointer has been away for about half a second. Without the pause, the diagonal path to a mega menu panel breaks the menu.
A transition-delay on the visibility change adds that pause in plain CSS.
Amazon’s category menu took a different route in 2013. Ben Kamens, a lead developer at Khan Academy at the time, studied it and released jQuery-menu-aim, which keeps a submenu open while the cursor heads toward it instead of waiting out a fixed timer.
CSS cannot read cursor direction, so that approach needs script.
How to Position the Submenu and Nested Levels
The parent list item gets position: relative. The submenu gets position: absolute with top: 100%, which opens it flush under its trigger. An absolute child anchors to its nearest positioned ancestor, and that’s the only reason the relative parent matters.
| Case | Offsets | Reason |
|---|
| Standard submenu | top: 100%; left: 0 | Opens flush below the trigger |
| Last item on the right | top: 100%; right: 0; left: auto | Stays inside the page edge |
| Nested fly-out | top: 0; left: 100% | Opens beside its parent item |
The z-index value decides what the submenu covers, but only inside its stacking context. A submenu at z-index: 9999 inside a header at z-index: 1 still loses to a section at z-index: 2 outside that header.
MDN’s stacking context reference states that position: fixed and position: sticky always create one. A sticky navigation bar therefore traps every dropdown inside it, whatever z-index the submenu carries.
The last top-level item is where submenus run past the viewport edge. Right-aligning that one submenu with right: 0 fixes it without touching the others.
Third-level menus use the same offsets as the fly-out row. Two levels cover most navigation structures, and a third multiplies the pointer-path problem. I’d avoid it.
How to Make a CSS Dropdown Menu Accessible
Tab has to reach every link in visual order, and Escape has to close the menu. Screen readers also need to know whether the menu is open.
CSS handles the tab order. The open state and Escape both need script.
Keyboard focus and :focus-within limits
:focus-within keeps the submenu open for as long as any link inside it holds focus. It cannot close the menu on demand, and it cannot report state to a screen reader.
- The parent trigger has to be a real link or button so it can take focus
- DOM order should match visual order, with submenu links directly after their parent
- Shift+Tab is worth testing, since leaving the first or last submenu link should close the menu
- Never write
outline: none without a replacement indicator
MDN warns that a replacement focus indicator still has to meet WCAG 1.4.11 Non-text Contrast.
Disclosure pattern or menu role
The W3C ARIA Authoring Practices Guide treats site navigation dropdowns as disclosure widgets, and its disclosure navigation example does not use the menu role. The menu and menubar roles demand composite focus management with arrow keys, and usually first-character navigation, which ordinary site navigation does not need.
For site links, the disclosure pattern is a button trigger inside a list item, with the list wrapped in a nav landmark. Tab moves through the buttons and links. Arrow keys, Home and End are optional extras.
The menu and menubar roles belong to application commands. Arrow keys become the primary way to move focus, and type-ahead jumps to an item by its first letter. That suits File and Edit style menus and doesn’t suit a link list.
The disclosure button carries aria-expanded and aria-controls. Both are ARIA attributes that only script can flip, so this is the one place where a CSS-built menu needs JavaScript for accessibility instead of looks.
How each CSS technique scores on the WCAG content on hover or focus criterion
W3C success criterion 1.4.13 covers any submenu that appears on hover or focus. Pure hover and focus-within menus fail the dismissible condition when they cover other content, since only moving the pointer or the focus closes them, and a magnifier user cannot clear a submenu that covers content.
A keydown handler for Escape fixes that. So does a submenu built with popover="auto", which closes on Escape natively.
Hoverable passes when the submenu is a DOM child of the hovered item and no gap separates the two. Persistent passes by default, because the submenu stays until hover or focus leaves, and a close delay only extends that.
The APG disclosure example states that its Escape behavior is necessary to meet this criterion.
Without a gap, a hover-only submenu already passes hoverable and persistent. Escape handling covers the remaining condition.
How Does a CSS Dropdown Menu Behave on Touch Screens and Small Viewports?
Touch browsers emulate hover when a finger taps, which leaves a hover-only submenu stuck open or sends the tap straight to the parent link.
The fix is to scope the hover rule to devices that can hover, with a media query on the interaction features.
@media (hover: hover) and (pointer: fine) {
.has-sub:hover > .sub { display: block; }
}
| Feature | Tests | Role here |
|---|
| hover: hover | Primary input can hover | Gates the hover rule |
| pointer: fine | Primary input is precise | Excludes Android long-press hover |
| any-hover: hover | Any attached input can hover | Skip for this rule |
Michelle Barker (CSS { In Real Life }, 2021) found that some Android versions evaluate hover: hover as true after a long press, which is why pointer: fine joins the query. The any-hover feature returns true on a touch tablet with a mouse attached, so finger taps keep triggering hover styles.
Below the mobile breakpoint, replace the horizontal bar with a stacked list opened by a button, the familiar hamburger menu pattern. Details and summary works as the toggle without extra script.
Tap size matters in the stacked list. WCAG 2.2 success criterion 2.5.8 sets a Level AA minimum of 24 by 24 CSS pixels per target, and the older Level AAA criterion 2.5.5 asks for 44 by 44 (W3C).
Padding on each submenu link covers it.
What Do the Popover API and CSS Anchor Positioning Change?
Quite a lot of the manual work moves into the browser. The Popover API takes over toggling and light dismiss, and it puts the submenu in the top layer. CSS anchor positioning tethers the submenu to its trigger, so there are no hand-tuned offsets and no script.
A button with popovertarget and a list with the popover attribute make a click-to-open submenu. An outside click closes it, and the browser promotes it to the top layer.
<button class="trigger" popovertarget="svc">Services</button>
<ul id="svc" class="sub" popover> ... </ul>
.trigger { anchor-name: --svc; }
.sub { position-anchor: --svc; position-area: bottom span-right; margin: 0; }
Popovers open on click, not hover. Hover and focus opening comes from interest invokers (the interestfor attribute), which MDN labels Experimental and which has so far shipped only in Chrome and Edge 142.
The anchor pair replaces the relative parent plus top: 100% combination. The submenu still needs absolute or fixed positioning, and a popover already gets fixed positioning from the browser stylesheet.
Browser compatibility changes fast here, so older sources go out of date quickly. Some tool pages still describe anchor positioning as Chrome-only, with Firefox and Safari unsupported.
Safari 26 shipped anchor positioning in September 2025 and Firefox 147 followed in January 2026. The January 2026 web.dev report and MDN compatibility data list both versions, so the older claim is out of date as of October 2026. For the Popover API, web.dev first dated Baseline to April 2024, then corrected that to January 27, 2025, after an iOS bug was fixed in Safari 18.3. MDN’s English page agrees, listing Baseline 2025 with an asterisk for parts with varying support. Some sources still show the April 2024 date, so cite the browser versions and check the label on the day you publish.
Here are the figures as of October 2026.
- The Popover API first shipped in Chrome 114, Firefox 125 and Safari 17 (web.dev)
- It has been Baseline Newly available since January 2025, after the Safari 18.3 iOS fix (MDN, web.dev)
- CSS anchor positioning arrived in Chrome 125, Safari 26 and Firefox 147 (MDN browser compatibility data)
- Its core properties became Baseline Newly available with Firefox 147 (web.dev, January 2026)
- Interest invokers remain Experimental, in Chrome and Edge 142 only (MDN)
Where a CSS Dropdown Menu Breaks and When to Use JavaScript Instead
Dropdowns usually break because an ancestor clips or re-anchors them. Escape handling and arrow-key focus management are where plain CSS stops being enough, and collision detection is the other one.
Smashing Magazine (March 2026) notes that an ancestor with overflow hidden, scroll or auto clips an absolutely positioned submenu even when that ancestor is not the containing block. The submenu escapes only when its containing block sits outside the clipping ancestor. Move the submenu out of that container or render it as a child of the body.
A transform, filter or perspective on an ancestor turns it into the containing block, so offsets measure from the wrong box. Remove the property or hoist the submenu.
If the trigger is a div or span, it cannot take focus and :focus-within never fires. Use a link or a button.
An absolute list taller than the viewport runs off screen with no scroll. Cap it with max-height and overflow-y: auto, or switch to the stacked layout.
For static two-level site navigation, stay with CSS and add a small Escape script. If the menu needs arrow keys and type-ahead, script it. Dynamic content or viewport collisions call for a positioning library such as Floating UI, or anchor positioning with its fallback rules.
Once a project needs menu role semantics, a JavaScript-driven menu is the honest choice.
Adobe’s accessibility team published Accessible Mega Menu, a jQuery plugin that adds arrow-key movement and letter-key focus on top of plain markup.
Teams that would rather not maintain that code can start from Bootstrap’s dropdown component, which wraps the toggling and positioning in script.
CSS Dropdown Menu FAQ
Can you build a mega menu with only CSS?
Yes. Move position: relative onto the nav instead of the list item, make the submenu a full-width absolute panel, and lay its columns out with CSS grid.
Escape handling still needs a few lines of script.
How do you center a submenu under its trigger?
Set left: 50% on the submenu and pull it back with transform: translateX(-50%). With CSS anchor positioning, position-area: bottom centers it with no offsets.
Keep the first and last top-level items aligned left and right, because a centered submenu there runs past the page edge.
How do you add a caret icon to the dropdown trigger?
Draw it with a ::after pseudo-element on the trigger. A small box with two borders, rotated 45 degrees, needs no icon file. A CSS triangle generator outputs the border values for the older zero-size version.
Rotate the caret with an [aria-expanded="true"] selector so it flips when the menu opens.
Is a Bootstrap or Tailwind dropdown better than a custom one?
Only when the project already uses that framework, because Bootstrap ships tested open, close and positioning script. Tailwind supplies utility classes, so its dropdown examples still need script for behavior.
A plain site with two menu levels runs fine on custom CSS.
Does :has() let you style the parent item when a submenu is open?
Yes. The :has() selector styles a parent from the state of its child, so .has-sub:has(:popover-open) > a highlights the trigger while a popover submenu is open.
For hover and focus alone, :hover and :focus-within on the parent already do the job.
Testing a CSS Dropdown Menu Before It Ships
Before shipping, I’d test with the keyboard alone, then on a real phone, then with a screen reader. Every pass has to reach each submenu link and close the menu again.
Start with the keyboard, since it costs nothing and exposes the focus bugs the other tests would only repeat. Press Tab, Shift+Tab and Escape. Then try a real phone, with a first tap and a tap away. Last, run a screen reader and listen for the announced state on open and close.
Passing all of that costs a small script and a second layout below the mobile breakpoint, so the menu stops being CSS-only.
This advice was verified in October 2026. It will change once interest invokers leave experimental status, since hover opening would then move from selectors into a declarative attribute.
A web accessibility checklist covers the audit steps for the rest of the page.