CSS Dropdown Menus: 16 Examples + the Trigger That Fits

Most CSS dropdown menus are two levels deep and need no library. A nested list with :hover plus :focus-within on the parent item opens the submenu for mouse and keyboard users alike.

Below are 16 CSS dropdown menu examples you can try and copy, followed by a guide to building, positioning and testing them.

By Bogdan Sandu · Updated October 26, 2026

Hover and focus dropdowns

Menus that open from CSS alone, with :hover for the mouse and :focus-within for the keyboard. Each one fixes a different weak spot of the basic pattern: the hover gap, the snap open, or the missing state on the parent item.

Hover plus :focus-within

  • :hover
  • :focus-within
  • position: absolute

Hover an item, or press Tab to move into the bar.

The baseline every other demo builds on. The submenu is a nested list inside its parent item, hidden with display: none and revealed by :hover or :focus-within on that item, so it opens for the mouse and for Tab. The parent item is the positioned ancestor, which is what keeps top: 100% under the trigger.

View code
.dd-basic {
  align-self: start;
  width: min(100%, 560px);
  min-height: 250px;
}
.dd-basic__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 16px;
  padding: 8px 10px 8px 18px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(15, 23, 42, .08);
}
.dd-basic__brand {
  color: #14213d;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 600;
  text-decoration: none;
}
.dd-basic__menu,
.dd-basic__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-basic__menu {
  display: flex;
}
.dd-basic__menu a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #1f2937;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
  transition: background-color .15s ease;
}
.dd-basic__menu a:hover {
  background: #eef2f8;
}
.dd-basic__menu a:focus-visible {
  outline: 2px solid #2152d9;
  outline-offset: 2px;
}
.dd-basic__has {
  position: relative;
}
.dd-basic__top::after {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  margin: 0 0 3px 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-basic__sub {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2;
  min-width: 200px;
  padding: 6px;
  border: 1px solid #e3e7ef;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 14px 30px rgba(15, 23, 42, .12);
}
.dd-basic__has:hover > .dd-basic__sub,
.dd-basic__has:focus-within > .dd-basic__sub {
  display: block;
}
.dd-basic__sub a {
  font-weight: 400;
}

Gap bridge and close delay

  • ::before bridge
  • transition-delay
  • :has(:checked)

Hover an item, or press Tab to move into the bar.

The submenu floats 14px below its trigger, so a transparent ::before strip fills the gap and keeps :hover alive while the pointer crosses it. The visibility change also waits 300ms before hiding, which forgives a diagonal path. Tick the checkbox to see the bridge.

View code
.dd-gap {
  display: flex;
  flex-direction: column;
  align-items: center;
  align-self: start;
  width: min(100%, 520px);
  min-height: 250px;
}
.dd-gap__nav {
  padding: 6px;
  border: 1px solid #eadfcd;
  border-radius: 999px;
  background: #fffaf2;
}
.dd-gap__menu,
.dd-gap__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-gap__menu {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
}
.dd-gap__menu a {
  display: block;
  padding: 10px 14px;
  border-radius: 999px;
  color: #2b2118;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
.dd-gap__menu a:hover {
  background: #f3e6d3;
}
.dd-gap__menu a:focus-visible {
  outline: 2px solid #b4410f;
  outline-offset: 2px;
}
.dd-gap__has {
  position: relative;
}
.dd-gap__has:is(:hover, :focus-within) > .dd-gap__top {
  background: #b4410f;
  color: #fff;
}
.dd-gap__sub {
  position: absolute;
  top: calc(100% + 14px);
  left: 50%;
  z-index: 2;
  width: 230px;
  padding: 8px;
  border-radius: 16px;
  background: #2b2118;
  box-shadow: 0 18px 36px rgba(43, 33, 24, .25);
  opacity: 0;
  visibility: hidden;
  transform: translateX(-50%);
  transition: opacity .15s ease .3s, visibility 0s linear .45s;
}
.dd-gap__sub::before {
  content: "";
  position: absolute;
  right: 0;
  bottom: 100%;
  left: 0;
  height: 14px;
}
.dd-gap__has:is(:hover, :focus-within) > .dd-gap__sub {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}
.dd-gap__sub a {
  border-radius: 10px;
  color: #fbeedd;
  font-weight: 400;
}
.dd-gap__menu .dd-gap__sub a:hover {
  background: #47382b;
}
.dd-gap__sub a:focus-visible {
  outline-color: #ffb38a;
}
.dd-gap:has(.dd-gap__show:checked) .dd-gap__sub::before {
  background: rgba(180, 65, 15, .18);
  outline: 2px dashed #b4410f;
  outline-offset: -2px;
}
.dd-gap__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: auto;
  color: #4a3f35;
  font-size: 14px;
  cursor: pointer;
}
.dd-gap__show {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #b4410f;
}
.dd-gap__show:focus-visible {
  outline: 2px solid #b4410f;
  outline-offset: 2px;
}

Fade and slide with visibility

  • visibility
  • opacity
  • prefers-reduced-motion

Hover an item, or press Tab to move into the bar.

visibility: hidden with opacity: 0 keeps the closed links out of the tab order while still allowing a transition. On close, visibility waits for the fade to finish; on open, the delay resets to 0s so the menu does not fade in invisible. The slide only runs when the visitor has not asked for reduced motion.

View code
.dd-fade {
  align-self: start;
  width: min(100%, 560px);
  min-height: 270px;
}
.dd-fade__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 12px;
  padding: 8px 8px 8px 18px;
  border: 1px solid #262d38;
  border-radius: 12px;
  background: #121720;
}
.dd-fade__brand {
  color: #c4f25c;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 17px;
  font-weight: 700;
  text-decoration: none;
}
.dd-fade__menu,
.dd-fade__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-fade__menu {
  display: flex;
}
.dd-fade__menu a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #e6e9ef;
  font-size: 15px;
  text-decoration: none;
  transition: background-color .15s ease;
}
.dd-fade__menu a:hover {
  background: #1f2632;
}
.dd-fade__menu a:focus-visible {
  outline: 2px solid #c4f25c;
  outline-offset: 2px;
}
.dd-fade__has {
  position: relative;
}
.dd-fade__sub {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2;
  width: min(250px, 80vw);
  padding: 6px;
  border: 1px solid #2c3442;
  border-radius: 12px;
  background: #181e29;
  box-shadow: 0 20px 40px rgba(0, 0, 0, .45);
  opacity: 0;
  visibility: hidden;
  transition: opacity .2s ease, visibility 0s linear .2s;
}
.dd-fade__has:is(:hover, :focus-within) > .dd-fade__sub {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}
.dd-fade__sub strong {
  display: block;
  font-weight: 600;
}
.dd-fade__sub span {
  display: block;
  color: #a3adbd;
  font-size: 13px;
}
@media (prefers-reduced-motion: no-preference) {
  .dd-fade__sub {
    transform: translateY(-8px);
    transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s;
  }
  .dd-fade__has:is(:hover, :focus-within) > .dd-fade__sub {
    transform: none;
    transition-delay: 0s;
  }
}

display: none that still animates

  • @starting-style
  • transition-behavior
  • allow-discrete

Hover an item, or press Tab to move into the bar.

The submenu keeps display: none when closed, and transition-behavior: allow-discrete lets display take part in the transition. @starting-style supplies the first frame, so the menu fades in from it. Firefox does not transition display yet, so there the menu still fades in but closes with a snap, which is a fine fallback.

View code
.dd-start {
  align-self: start;
  width: min(100%, 520px);
  min-height: 260px;
}
.dd-start__nav {
  padding: 8px;
  border-radius: 16px;
  background: #f1edff;
}
.dd-start__menu,
.dd-start__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-start__menu {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.dd-start__menu a {
  display: block;
  padding: 10px 14px;
  border-radius: 10px;
  color: #2a1f5c;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
  transition: background-color .15s ease;
}
.dd-start__menu a:hover {
  background: #e2dafd;
}
.dd-start__menu a:focus-visible {
  outline: 2px solid #5b3df5;
  outline-offset: 2px;
}
.dd-start__menu .dd-start__cta {
  margin-left: 4px;
  background: #4f30e8;
  color: #fff;
}
.dd-start__menu .dd-start__cta:hover {
  background: #3d20cf;
}
.dd-start__has {
  position: relative;
}
.dd-start__top::after {
  content: "+";
  display: inline-block;
  margin-left: 6px;
  font-weight: 600;
}
.dd-start__sub {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2;
  min-width: 200px;
  padding: 6px;
  border: 1px solid #d9d0fb;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 16px 32px rgba(42, 31, 92, .16);
  opacity: 0;
  transition: opacity .2s ease, display .2s allow-discrete;
}
.dd-start__has:is(:hover, :focus-within) > .dd-start__sub {
  display: block;
  opacity: 1;
}
@starting-style {
  .dd-start__has:is(:hover, :focus-within) > .dd-start__sub {
    opacity: 0;
  }
}
.dd-start__sub a {
  font-weight: 400;
}
@media (prefers-reduced-motion: no-preference) {
  .dd-start__sub {
    translate: 0 -6px;
    transition: opacity .2s ease, translate .2s ease, display .2s allow-discrete;
  }
  .dd-start__has:is(:hover, :focus-within) > .dd-start__sub {
    translate: 0 0;
  }
  @starting-style {
    .dd-start__has:is(:hover, :focus-within) > .dd-start__sub {
      translate: 0 -6px;
    }
  }
}

:has() marks the open item

  • :has()
  • :not()
  • dim siblings

Hover an item, or press Tab to move into the bar.

The bar asks whether any of its items is hovered or holds focus. When one is, :has() dims the other top links and draws an amber bar under the open one, so the eye follows the menu that is actually showing. Browsers without :has() skip the dimming and keep a working menu.

View code
.dd-has {
  align-self: start;
  width: min(100%, 520px);
  min-height: 240px;
}
.dd-has__nav {
  padding: 0 10px;
  border-radius: 12px;
  background: #0f3d3e;
}
.dd-has__menu,
.dd-has__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-has__menu {
  display: flex;
  flex-wrap: wrap;
}
.dd-has__item {
  position: relative;
}
.dd-has__top {
  display: block;
  padding: 16px 14px 13px;
  border-bottom: 3px solid transparent;
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: .01em;
  text-decoration: none;
  transition: opacity .2s ease, border-color .2s ease;
}
.dd-has__top:focus-visible,
.dd-has__sub a:focus-visible {
  outline: 2px solid #f2b33d;
  outline-offset: -2px;
}
.dd-has__item:is(:hover, :focus-within) > .dd-has__top {
  border-bottom-color: #f2b33d;
}
.dd-has__menu:has(> .dd-has__item:is(:hover, :focus-within)) > .dd-has__item:not(:hover, :focus-within) > .dd-has__top {
  opacity: .6;
}
.dd-has__sub {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2;
  min-width: 200px;
  padding: 6px;
  border-radius: 0 0 12px 12px;
  background: #fff;
  box-shadow: 0 16px 30px rgba(15, 61, 62, .2);
}
.dd-has__item:is(:hover, :focus-within) > .dd-has__sub {
  display: block;
}
.dd-has__sub a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #12302f;
  font-size: 15px;
  text-decoration: none;
}
.dd-has__sub a:hover {
  background: #e7f1ef;
}

Click and disclosure dropdowns

Menus that open on click, Enter or Space. Details and summary and the popover attribute need no script, while the disclosure buttons use a few lines to flip aria-expanded and close on Escape, as the APG disclosure navigation example does.

Details and summary, one open at a time

  • details / summary
  • name attribute
  • [open]

Click a trigger to open it.

Each top item is a details element, so Enter, Space and clicks toggle it with no script. Giving both the same name makes them exclusive: opening Events closes Browse. The caret rotates from the [open] attribute, and summary loses its default marker.

View code
.dd-det {
  align-self: start;
  width: min(100%, 520px);
  min-height: 260px;
}
.dd-det__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 12px;
  padding: 8px 8px 8px 18px;
  border: 1px solid #e6dccb;
  border-radius: 6px;
  background: #fbf7f0;
}
.dd-det__brand {
  color: #3b2a1a;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-style: italic;
  font-weight: 600;
  text-decoration: none;
}
.dd-det__items {
  display: flex;
  gap: 4px;
}
.dd-det__item {
  position: relative;
}
.dd-det__item > summary {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  border-radius: 6px;
  color: #3b2a1a;
  font-size: 15px;
  font-weight: 500;
  list-style: none;
  cursor: pointer;
  transition: background-color .15s ease;
}
.dd-det__item > summary::-webkit-details-marker {
  display: none;
}
.dd-det__item > summary::after {
  content: "";
  width: 6px;
  height: 6px;
  margin-top: -3px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-det__item > summary:hover,
.dd-det__item[open] > summary {
  background: #f0e6d6;
}
.dd-det__item[open] > summary::after {
  margin-top: 3px;
  transform: rotate(225deg);
}
.dd-det__item > summary:focus-visible,
.dd-det__sub a:focus-visible {
  outline: 2px solid #8a4b14;
  outline-offset: 2px;
}
.dd-det__sub {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 2;
  min-width: 190px;
  margin: 0;
  padding: 6px;
  list-style: none;
  border: 1px solid #e6dccb;
  border-radius: 6px;
  background: #fff;
  box-shadow: 0 14px 28px rgba(59, 42, 26, .14);
}
.dd-det__sub a {
  display: block;
  padding: 9px 12px;
  border-radius: 4px;
  color: #3b2a1a;
  font-size: 15px;
  text-decoration: none;
}
.dd-det__sub a:hover {
  background: #f6efe3;
}
@media (prefers-reduced-motion: no-preference) {
  .dd-det__item > summary::after {
    transition: transform .2s ease, margin .2s ease;
  }
}

Disclosure buttons with Escape

  • aria-expanded
  • aria-controls
  • Escape key

Click a trigger to open it.

The APG disclosure navigation pattern: each trigger is a real button with aria-expanded and aria-controls, and CSS shows the list whenever the attribute is true. The script opens one menu at a time, closes it on Escape and returns focus to its button, and closes it when focus or a click lands outside the nav.

View code
.dd-disc {
  align-self: start;
  width: min(100%, 520px);
  min-height: 240px;
}
.dd-disc__nav {
  padding: 8px;
  border-radius: 14px;
  background: #e6f4ec;
}
.dd-disc__menu,
.dd-disc__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-disc__menu {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.dd-disc__item {
  position: relative;
}
.dd-disc__link,
.dd-disc__btn {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #12372a;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: background-color .15s ease;
}
.dd-disc__link:hover,
.dd-disc__btn:hover,
.dd-disc__btn[aria-expanded="true"] {
  background: #c9e8d6;
}
.dd-disc__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  margin-top: -3px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-disc__btn[aria-expanded="true"]::after {
  margin-top: 3px;
  transform: rotate(225deg);
}
.dd-disc__link:focus-visible,
.dd-disc__btn:focus-visible,
.dd-disc__sub a:focus-visible {
  outline: 2px solid #0b6b48;
  outline-offset: 2px;
}
.dd-disc__sub {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 2;
  min-width: 190px;
  padding: 6px;
  border: 1px solid #cfe5d8;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 16px 30px rgba(18, 55, 42, .14);
}
.dd-disc__btn[aria-expanded="false"] + .dd-disc__sub {
  display: none;
}
.dd-disc__sub a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #12372a;
  font-size: 15px;
  text-decoration: none;
}
.dd-disc__sub a:hover {
  background: #eef8f2;
}
@media (prefers-reduced-motion: no-preference) {
  .dd-disc__btn::after {
    transition: transform .2s ease, margin .2s ease;
  }
}

Popover with anchor positioning

  • popover
  • position-area
  • :popover-open

Click a trigger to open it.

A workspace switcher built on popover="auto": the browser handles toggling, Escape, light dismiss and the top layer. Anchor positioning tethers the list under its button and flips it when there is no room. Without anchor support the list keeps the default popover position, centered in the viewport.

View code
.dd-pop {
  align-self: start;
  min-height: 290px;
}
.dd-pop__btn {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 48px;
  padding: 6px 16px 6px 8px;
  border: 1px solid #d5d9e2;
  border-radius: 12px;
  background: #fff;
  color: #0f172a;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  anchor-name: --dd-pop;
  transition: border-color .15s ease, box-shadow .15s ease;
}
.dd-pop__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  margin: -3px 0 0 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-pop__btn:hover {
  border-color: #9aa3b5;
}
.dd-pop__btn:focus-visible {
  outline: 3px solid #e2553f;
  outline-offset: 2px;
}
.dd-pop:has(.dd-pop__list:popover-open) .dd-pop__btn {
  border-color: #e2553f;
  box-shadow: 0 0 0 3px rgba(226, 85, 63, .18);
}
.dd-pop:has(.dd-pop__list:popover-open) .dd-pop__btn::after {
  margin-top: 3px;
  transform: rotate(225deg);
}
.dd-pop__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.dd-pop__mark {
  display: inline-grid;
  place-items: center;
  flex: none;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  background: #e2553f;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
}
.dd-pop__mark--b {
  background: #1d6f5f;
}
.dd-pop__mark--c {
  background: #3b4a9b;
}
.dd-pop__list {
  width: 250px;
  padding: 8px;
  border: 1px solid #e1e4eb;
  border-radius: 14px;
  background: #fff;
  color: #0f172a;
  box-shadow: 0 18px 40px rgba(15, 23, 42, .16);
}
.dd-pop__list ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-pop__title {
  margin: 4px 8px 6px;
  color: #5a6478;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.dd-pop__list a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  border-radius: 10px;
  color: #0f172a;
  font-size: 15px;
  text-decoration: none;
}
.dd-pop__list a:hover {
  background: #f3f4f7;
}
.dd-pop__list a[aria-current="true"] {
  font-weight: 600;
}
.dd-pop__list a[aria-current="true"]::after {
  content: "";
  width: 5px;
  height: 10px;
  margin: -3px 4px 0 auto;
  border-right: 2px solid #e2553f;
  border-bottom: 2px solid #e2553f;
  transform: rotate(45deg);
}
.dd-pop__list a:focus-visible {
  outline: 2px solid #e2553f;
  outline-offset: -2px;
}
.dd-pop__list .dd-pop__new {
  margin-top: 6px;
  padding: 10px;
  border-top: 1px solid #eceef2;
  border-radius: 0 0 10px 10px;
  color: #b8321f;
  font-weight: 600;
}
@supports (position-area: bottom) {
  .dd-pop__list {
    inset: auto;
    margin: 6px 0 0;
    position-anchor: --dd-pop;
    position-area: bottom span-right;
    position-try-fallbacks: flip-block, flip-inline;
  }
}

Split button with more actions

  • split button
  • right: 0
  • Escape key

Click a trigger to open it.

The main half publishes, the caret half opens a short list of related actions. The list is right-aligned with right: 0, the same fix the last item of a nav bar needs, and the caret button carries its own aria-label and aria-expanded. Escape closes the list and puts focus back on the caret.

View code
.dd-split {
  align-self: start;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  width: min(100%, 420px);
  min-height: 230px;
  padding-top: 20px;
}
.dd-split__group {
  display: flex;
  position: relative;
}
.dd-split__main,
.dd-split__more {
  min-height: 48px;
  border: 0;
  background: #f5c542;
  color: #1a1300;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.dd-split__main {
  padding: 0 20px;
  border-radius: 10px 0 0 10px;
}
.dd-split__more {
  display: grid;
  place-items: center;
  width: 48px;
  border-left: 1px solid #a07a12;
  border-radius: 0 10px 10px 0;
}
.dd-split__more::after {
  content: "";
  width: 7px;
  height: 7px;
  margin-top: -4px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-split__more[aria-expanded="true"]::after {
  margin-top: 4px;
  transform: rotate(225deg);
}
.dd-split__main:hover,
.dd-split__more:hover,
.dd-split__more[aria-expanded="true"] {
  background: #ffd866;
}
.dd-split__main:focus-visible,
.dd-split__more:focus-visible {
  position: relative;
  z-index: 1;
  outline: 3px solid #fff;
  outline-offset: 2px;
}
.dd-split__list {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 2;
  width: 220px;
  margin: 0;
  padding: 6px;
  list-style: none;
  border: 1px solid #2f3442;
  border-radius: 12px;
  background: #1b1f29;
  box-shadow: 0 20px 40px rgba(0, 0, 0, .5);
}
.dd-split__more[aria-expanded="false"] + .dd-split__list {
  display: none;
}
.dd-split__list button {
  display: block;
  width: 100%;
  padding: 11px 12px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: #eef0f5;
  font: inherit;
  font-size: 15px;
  text-align: left;
  cursor: pointer;
}
.dd-split__list button:hover {
  background: #2a303d;
}
.dd-split__list button:focus-visible {
  outline: 2px solid #f5c542;
  outline-offset: -2px;
}
@media (prefers-reduced-motion: no-preference) {
  .dd-split__more::after {
    transition: transform .2s ease, margin .2s ease;
  }
}

Avatar account menu

  • disclosure
  • focus return
  • outside click
Fieldnote

Maya Rosenmaya@fieldnote.example

Sign out

Click a trigger to open it.

The avatar button sits at the right end of an app header, so its card opens right-aligned. A header block names the signed-in account, then come plain links and a separate sign-out row. It is still a disclosure, not role="menu": Tab moves through the links and Escape closes the card.

View code
.dd-acct {
  align-self: start;
  width: min(100%, 520px);
  min-height: 310px;
}
.dd-acct__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px 10px 18px;
  border: 1px solid #e4e1da;
  border-radius: 14px;
  background: #fdfcf9;
}
.dd-acct__app {
  color: #1d2a24;
  font-size: 17px;
  font-weight: 700;
}
.dd-acct__wrap {
  position: relative;
}
.dd-acct__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 2px solid #fff;
  border-radius: 50%;
  background: #2f6f5e;
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  box-shadow: 0 0 0 1px #c8d6cf;
  cursor: pointer;
}
.dd-acct__btn[aria-expanded="true"] {
  box-shadow: 0 0 0 3px #9cc7b8;
}
.dd-acct__btn:focus-visible {
  outline: 3px solid #1d2a24;
  outline-offset: 3px;
}
.dd-acct__card {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 2;
  width: min(250px, 78vw);
  padding: 6px;
  border: 1px solid #e4e1da;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 18px 36px rgba(29, 42, 36, .16);
}
.dd-acct__btn[aria-expanded="false"] + .dd-acct__card {
  display: none;
}
.dd-acct__who {
  margin: 0 0 6px;
  padding: 8px 10px 12px;
  border-bottom: 1px solid #eeebe4;
}
.dd-acct__who strong {
  display: block;
  color: #1d2a24;
  font-size: 15px;
}
.dd-acct__who span {
  display: block;
  overflow-wrap: anywhere;
  color: #5d6560;
  font-size: 13px;
}
.dd-acct__card ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-acct__card a {
  display: block;
  padding: 9px 10px;
  border-radius: 8px;
  color: #1d2a24;
  font-size: 15px;
  text-decoration: none;
}
.dd-acct__card a:hover {
  background: #f2f5f3;
}
.dd-acct__card a:focus-visible {
  outline: 2px solid #2f6f5e;
  outline-offset: -2px;
}
.dd-acct__card .dd-acct__out {
  margin-top: 6px;
  border-top: 1px solid #eeebe4;
  border-radius: 0 0 8px 8px;
  color: #a3321f;
}

Fly-outs and mega menus

Bigger structures built on the same hover and focus rules: a three-level fly-out that flips at the edge, a mega menu with columns, and a product menu with icons and descriptions.

Three-level fly-out that flips

  • left: 100%
  • right: 100%
  • container query

Hover an item, or press Tab to move through the levels.

Second-level lists hang below their item, third-level lists fly out beside theirs with top: 0 and left: 100%. Under the last top item everything mirrors: right: 0 for the panel and right: 100% for the fly-out, so nothing leaves the edge. When the container is too narrow for side-by-side levels, a container query drops the third level inline instead.

View code
.dd-fly {
  align-self: start;
  width: min(100%, 560px);
  min-height: 300px;
  container: dd-fly / inline-size;
}
.dd-fly__nav {
  border-bottom: 2px solid #2d2a26;
  background: #fff;
}
.dd-fly__menu,
.dd-fly__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-fly__menu {
  display: flex;
  justify-content: space-between;
}
.dd-fly__has {
  position: relative;
}
.dd-fly__top {
  display: block;
  padding: 14px 16px;
  color: #2d2a26;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
}
.dd-fly__top:hover,
.dd-fly__has:is(:hover, :focus-within) > .dd-fly__top {
  background: #2d2a26;
  color: #f7f1e6;
}
.dd-fly__sub {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2;
  width: 180px;
  padding: 6px 0;
  background: #2d2a26;
  box-shadow: 0 16px 30px rgba(45, 42, 38, .3);
}
.dd-fly__sub--side {
  top: -6px;
  left: 100%;
  z-index: 3;
  background: #3d3933;
}
.dd-fly__has--end > .dd-fly__sub {
  right: 0;
  left: auto;
}
.dd-fly__has--end .dd-fly__sub--side {
  right: 100%;
  left: auto;
}
.dd-fly__has:is(:hover, :focus-within) > .dd-fly__sub {
  display: block;
}
.dd-fly__sub a {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 16px;
  color: #f7f1e6;
  font-size: 15px;
  text-decoration: none;
}
.dd-fly__sub a:hover {
  background: #57514a;
}
.dd-fly__parent::after {
  content: "";
  width: 6px;
  height: 6px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-fly__has--end .dd-fly__parent::after {
  order: -1;
  transform: rotate(-135deg);
}
.dd-fly__top:focus-visible,
.dd-fly__sub a:focus-visible {
  outline: 2px solid #e0a43a;
  outline-offset: -2px;
}
@container dd-fly (max-width: 480px) {
  .dd-fly__sub .dd-fly__sub--side,
  .dd-fly__has--end .dd-fly__sub--side {
    position: static;
    width: auto;
    padding: 0 0 4px;
    box-shadow: none;
  }
  .dd-fly__sub--side a {
    padding-left: 30px;
  }
  .dd-fly__parent::after,
  .dd-fly__has--end .dd-fly__parent::after {
    order: 0;
    transform: rotate(135deg);
  }
}

Mega menu with columns

  • CSS grid
  • auto-fit
  • full-width panel

Hover an item, or press Tab to move through the levels.

position: relative moves from the list item to the nav, so the panel spans the whole bar instead of hanging under one link. Inside, an auto-fit grid lays out the link columns and a featured trip, and the columns wrap on their own as the space shrinks. The feature card drops out below 560px of container width.

View code
.dd-mega {
  align-self: start;
  width: min(100%, 680px);
  min-height: 400px;
  container: dd-mega / inline-size;
}
.dd-mega__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 12px;
  position: relative;
  padding: 6px 8px 6px 18px;
  border-radius: 12px;
  background: #172554;
}
.dd-mega__brand {
  color: #fff;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 600;
  text-decoration: none;
}
.dd-mega__menu,
.dd-mega__col ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-mega__menu {
  display: flex;
}
.dd-mega__top {
  display: block;
  padding: 10px 14px;
  border-radius: 8px;
  color: #e8edff;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
}
.dd-mega__top:hover,
.dd-mega__has:is(:hover, :focus-within) > .dd-mega__top {
  background: #fbbf24;
  color: #172554;
}
.dd-mega__top:focus-visible {
  outline: 2px solid #fbbf24;
  outline-offset: 2px;
}
.dd-mega__panel {
  display: none;
  grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
  gap: 14px 16px;
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  left: 0;
  z-index: 2;
  padding: 18px;
  border: 1px solid #dfe3f0;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 22px 44px rgba(23, 37, 84, .18);
}
.dd-mega__has:is(:hover, :focus-within) > .dd-mega__panel {
  display: grid;
}
.dd-mega__head {
  margin: 0 0 6px;
  color: #92400e;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.dd-mega__col a {
  display: block;
  padding: 6px 0;
  color: #172554;
  font-size: 15px;
  text-decoration: none;
}
.dd-mega__col a:hover {
  color: #92400e;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.dd-mega__col a:focus-visible,
.dd-mega__feature:focus-visible {
  outline: 2px solid #172554;
  outline-offset: 2px;
}
.dd-mega__feature {
  display: flex;
  flex-direction: column;
  gap: 8px;
  color: #172554;
  font-size: 14px;
  font-weight: 600;
  text-decoration: none;
}
.dd-mega__feature img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 10px;
}
@container dd-mega (max-width: 559px) {
  .dd-mega__feature {
    display: none;
  }
}

Product menu with icons

  • inline SVG
  • two-column grid
  • opacity fade

Hover an item, or press Tab to move through the levels.

A SaaS-style product dropdown on a dark navbar: each link carries an icon tile, a title and one line of description, laid out in two columns that collapse to one when the panel is narrow. The whole entry is the link, so the target is large, and the icons are hidden from assistive technology.

View code
.dd-prod {
  align-self: start;
  width: min(100%, 620px);
  min-height: 330px;
  container: dd-prod / inline-size;
}
.dd-prod__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 12px;
  position: relative;
  padding: 6px 8px 6px 18px;
  border: 1px solid #2a2f3a;
  border-radius: 12px;
  background: #15181f;
}
.dd-prod__brand {
  color: #fff;
  font-size: 17px;
  font-weight: 700;
  text-decoration: none;
}
.dd-prod__menu,
.dd-prod__panel {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-prod__menu {
  display: flex;
}
.dd-prod__top {
  display: block;
  padding: 10px 14px;
  border-radius: 8px;
  color: #d9dee8;
  font-size: 15px;
  text-decoration: none;
  transition: background-color .15s ease;
}
.dd-prod__top:hover,
.dd-prod__has:is(:hover, :focus-within) > .dd-prod__top {
  background: #252a35;
  color: #fff;
}
.dd-prod__top:focus-visible,
.dd-prod__panel a:focus-visible {
  outline: 2px solid #ff9f5a;
  outline-offset: 2px;
}
.dd-prod__panel {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px;
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  left: 0;
  z-index: 2;
  padding: 8px;
  border: 1px solid #2f3542;
  border-radius: 14px;
  background: #1c2029;
  box-shadow: 0 24px 48px rgba(0, 0, 0, .5);
  opacity: 0;
  visibility: hidden;
  transition: opacity .18s ease, visibility 0s linear .18s;
}
.dd-prod__has:is(:hover, :focus-within) > .dd-prod__panel {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}
.dd-prod__panel a {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px;
  border-radius: 10px;
  color: #a9b1c0;
  font-size: 13px;
  line-height: 1.4;
  text-decoration: none;
}
.dd-prod__panel a:hover {
  background: #272c37;
}
.dd-prod__panel svg {
  flex: none;
  width: 36px;
  height: 36px;
  padding: 7px;
  border-radius: 9px;
  background: #3a2618;
  fill: none;
  stroke: #ff9f5a;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.dd-prod__panel strong {
  display: block;
  color: #fff;
  font-size: 15px;
  font-weight: 600;
}
@container dd-prod (max-width: 480px) {
  .dd-prod__panel {
    grid-template-columns: 1fr;
  }
  .dd-prod__panel a {
    padding: 9px 10px;
  }
}

Dropdowns on small screens

What happens below the breakpoint. A container query turns the bar into an accordion, and the phone frames show a hamburger panel with exclusive sections and a long list capped with max-height.

Bar on wide, accordion on narrow

  • container query
  • (hover: hover)
  • aria-expanded

Tap the buttons to open each menu.

One markup, two layouts. In a wide container the buttons sit in a row and their lists drop down; in a narrow one a container query stacks everything and the same lists open inline as an accordion. Hover opening is wrapped in (hover: hover) and (pointer: fine), so touch screens only get the tap.

View code
.dd-resp {
  box-sizing: border-box;
  justify-self: stretch;
  width: 100%;
  min-height: 330px;
  padding: 24px;
  background: #f4f1ea;
  container: dd-resp / inline-size;
}
.dd-resp__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  max-width: 900px;
  margin: 0 auto;
  padding: 8px 8px 8px 20px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 6px 20px rgba(40, 50, 30, .08);
}
.dd-resp__brand {
  color: #27401f;
  font-family: "Fraunces", serif;
  font-size: 19px;
  font-weight: 600;
  text-decoration: none;
}
.dd-resp__menu,
.dd-resp__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.dd-resp__menu {
  display: flex;
  gap: 4px;
}
.dd-resp__item {
  position: relative;
}
.dd-resp__btn,
.dd-resp__link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  min-height: 44px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #27401f;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
.dd-resp__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  margin-top: -3px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-resp__btn[aria-expanded="true"]::after {
  margin-top: 3px;
  transform: rotate(225deg);
}
.dd-resp__btn:hover,
.dd-resp__link:hover,
.dd-resp__btn[aria-expanded="true"] {
  background: #e7efdc;
}
.dd-resp__btn:focus-visible,
.dd-resp__link:focus-visible,
.dd-resp__sub a:focus-visible {
  outline: 2px solid #3f7a2d;
  outline-offset: 2px;
}
.dd-resp__sub {
  display: none;
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 2;
  min-width: 200px;
  padding: 6px;
  border: 1px solid #dfe6d4;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 16px 30px rgba(39, 64, 31, .14);
}
.dd-resp__btn[aria-expanded="true"] + .dd-resp__sub {
  display: block;
}
.dd-resp__sub a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #27401f;
  font-size: 15px;
  text-decoration: none;
}
.dd-resp__sub a:hover {
  background: #f1f6ea;
}
@media (hover: hover) and (pointer: fine) {
  .dd-resp__item:hover > .dd-resp__sub {
    display: block;
  }
}
@container dd-resp (max-width: 560px) {
  .dd-resp__nav {
    flex-direction: column;
    align-items: stretch;
    padding: 12px;
  }
  .dd-resp__brand {
    padding: 4px 6px 8px;
  }
  .dd-resp__menu {
    flex-direction: column;
  }
  .dd-resp__sub,
  .dd-resp__item:hover > .dd-resp__sub {
    display: none;
    position: static;
    min-width: 0;
    margin: 2px 0 6px;
    border: 0;
    border-left: 3px solid #b9d3a3;
    border-radius: 0;
    box-shadow: none;
  }
  .dd-resp__btn[aria-expanded="true"] + .dd-resp__sub {
    display: block;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .dd-resp__btn::after {
    transition: transform .2s ease, margin .2s ease;
  }
}

Hamburger panel with exclusive sections

  • aria-expanded
  • hidden
  • details name

Tap the buttons to open each menu.

On a phone the dropdown becomes a panel under the header. The menu button toggles the hidden attribute and aria-expanded, and inside the panel each submenu is a details element sharing one name, so opening a section closes the last one. Rows are at least 48px tall for thumbs.

View code
.dd-ph {
  position: relative;
  z-index: 2;
}
.dd-ph__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px 8px 18px;
  border-bottom: 1px solid #e3e8e1;
  background: #fff;
}
.dd-ph__logo {
  color: #2e4a2a;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 600;
  text-decoration: none;
}
.dd-ph__toggle {
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #2e4a2a;
  border-radius: 999px;
  background: #fff;
  color: #2e4a2a;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.dd-ph__toggle[aria-expanded="true"] {
  background: #2e4a2a;
  color: #fff;
}
.dd-ph__toggle:focus-visible,
.dd-ph__sec > summary:focus-visible,
.dd-ph__panel a:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
}
.dd-ph__panel {
  position: absolute;
  top: 100%;
  right: 0;
  left: 0;
  padding: 8px 12px 14px;
  border-bottom: 1px solid #e3e8e1;
  background: #fbfcfa;
  box-shadow: 0 18px 30px rgba(46, 74, 42, .14);
}
.dd-ph__panel[hidden] {
  display: none;
}
.dd-ph__sec {
  border-bottom: 1px solid #e3e8e1;
}
.dd-ph__sec > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 48px;
  padding: 0 6px;
  color: #1f2f1c;
  font-size: 16px;
  font-weight: 600;
  list-style: none;
  cursor: pointer;
}
.dd-ph__sec > summary::-webkit-details-marker {
  display: none;
}
.dd-ph__sec > summary::after {
  content: "+";
  font-size: 22px;
  font-weight: 400;
  line-height: 1;
}
.dd-ph__sec[open] > summary::after {
  content: "\2212";
}
.dd-ph__sec ul {
  margin: 0 0 8px;
  padding: 0;
  list-style: none;
}
.dd-ph__sec a,
.dd-ph__plain {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 14px;
  border-radius: 8px;
  color: #2e4a2a;
  font-size: 15px;
  text-decoration: none;
}
.dd-ph__sec a:hover,
.dd-ph__plain:hover {
  background: #edf3ea;
}
.dd-ph__panel .dd-ph__plain {
  padding: 0 6px;
  color: #1f2f1c;
  font-size: 16px;
  font-weight: 600;
}

Long list capped with max-height

  • max-height
  • overflow-y: auto
  • overscroll-behavior
Pick up from

Tap the buttons to open each menu.

A store picker with fourteen locations would run off a phone screen as a plain absolute list. max-height with overflow-y: auto keeps it inside the view and scrollable, and overscroll-behavior: contain stops the page behind it from scrolling once the list hits its end. Picking a store updates the button label and closes the list.

View code
.dd-store {
  position: relative;
  z-index: 2;
  padding: 12px 16px;
  border-bottom: 1px solid #ece6f5;
  background: #fff;
}
.dd-store__bar {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.dd-store__hint {
  color: #5b5470;
  font-size: 13px;
}
.dd-store__btn {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 48px;
  padding: 0 14px;
  border: 1px solid #cfc6e2;
  border-radius: 12px;
  background: #f8f5fd;
  color: #2b1f4a;
  font: inherit;
  font-size: 16px;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.dd-store__btn::after {
  content: "";
  flex: none;
  width: 7px;
  height: 7px;
  margin: -4px 4px 0 12px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.dd-store__btn[aria-expanded="true"] {
  border-color: #6d3fd1;
}
.dd-store__btn[aria-expanded="true"]::after {
  margin-top: 4px;
  transform: rotate(225deg);
}
.dd-store__btn:focus-visible,
.dd-store__list button:focus-visible {
  outline: 2px solid #6d3fd1;
  outline-offset: 2px;
}
.dd-store__list {
  position: absolute;
  top: calc(100% - 6px);
  right: 16px;
  left: 16px;
  max-height: 300px;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  overscroll-behavior: contain;
  list-style: none;
  border: 1px solid #ddd4ee;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 18px 36px rgba(43, 31, 74, .18);
}
.dd-store__list[hidden] {
  display: none;
}
.dd-store__list button {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: #2b1f4a;
  font: inherit;
  font-size: 15px;
  text-align: left;
  cursor: pointer;
}
.dd-store__list button:hover {
  background: #f3eefc;
}
.dd-store__list button[aria-pressed="true"] {
  background: #ede5fb;
  font-weight: 600;
}
.dd-store__list button[aria-pressed="true"]::after {
  content: "";
  width: 5px;
  height: 10px;
  margin: -3px 4px 0 0;
  border-right: 2px solid #6d3fd1;
  border-bottom: 2px solid #6d3fd1;
  transform: rotate(45deg);
}

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.

  1. 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.
  2. Nest a second ul inside the li that owns the submenu. A submenu placed outside its parent item breaks the hover relationship.
  3. 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.
  4. 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.

MethodOpens onKeyboard and touchMain failure
:hover alonePointer over parentNo keyboard, unreliable tapSubmenu never appears for Tab users
:hover plus :focus-withinPointer or Tab focusKeyboard yes, touch partialNo Escape to close
details and summaryClick, Enter, SpaceBoth workNo close on outside click
Checkbox hackClick on a labelTouch works, keyboard weakWrong semantics for a menu
Popover APIButton click, light dismissBoth workNewer 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.

CaseOffsetsReason
Standard submenutop: 100%; left: 0Opens flush below the trigger
Last item on the righttop: 100%; right: 0; left: autoStays inside the page edge
Nested fly-outtop: 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; }
}
FeatureTestsRole here
hover: hoverPrimary input can hoverGates the hover rule
pointer: finePrimary input is preciseExcludes Android long-press hover
any-hover: hoverAny attached input can hoverSkip 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.