CSS Hamburger Menu: 16 Animated Examples You Can Copy

A CSS hamburger menu hides the main navigation links behind a three-bar button on narrow screens. A media query shows the button below a chosen width, CSS draws the icon and the panel, and the open or closed state sits on a button element. It works on phones and on any desktop window that gets too narrow for the links to fit on one line.

Before the animation, there's the label. WebAIM's 2026 analysis of 1,000,000 home pages found empty buttons, meaning buttons with no accessible name, on 30.6% of them. A bare three-bar icon with no text or aria-label falls into that group.

Below are 16 CSS hamburger menu examples you can click and copy: icon animations, menu panels that open in a phone frame, and three versions that run without JavaScript. The guide after the examples covers the toggle mechanisms, a step-by-step build, accessibility and the bugs that break menus.

By Bogdan Sandu · Updated October 26, 2026

Hamburger icon animations

Six ways to turn three bars into a close icon. Click each one. Every icon is a real button with aria-expanded, and the animation is driven by that attribute, so the visual state and the announced state never drift apart.

Squeeze, then cross

  • transition-delay
  • ::before / ::after
  • aria-expanded

Click the icon to toggle it.

The two-step morph most native apps use. The outer bars slide to the center first, then rotate into the X, and closing runs the same steps in reverse. Swapping which property carries the delay in the open state is what reverses the order.

View code
.hb-squeeze {
  display: grid;
  place-items: center;
  width: 72px;
  height: 72px;
  padding: 0;
  border: 0;
  border-radius: 20px;
  background: #fff;
  color: #080c14;
  box-shadow: 0 10px 30px rgba(8, 12, 20, .1);
  cursor: pointer;
}
.hb-squeeze span,
.hb-squeeze span::before,
.hb-squeeze span::after {
  display: block;
  width: 32px;
  height: 3px;
  border-radius: 3px;
  background: currentColor;
}
.hb-squeeze span {
  position: relative;
  transition: background-color 0s .18s;
}
.hb-squeeze span::before,
.hb-squeeze span::after {
  content: "";
  position: absolute;
  left: 0;
  transition: top .18s .18s ease, transform .18s ease;
}
.hb-squeeze span::before {
  top: -10px;
}
.hb-squeeze span::after {
  top: 10px;
}
.hb-squeeze[aria-expanded="true"] span {
  background-color: transparent;
}
.hb-squeeze[aria-expanded="true"] span::before,
.hb-squeeze[aria-expanded="true"] span::after {
  top: 0;
  transition: top .18s ease, transform .18s .18s ease;
}
.hb-squeeze[aria-expanded="true"] span::before {
  transform: rotate(45deg);
}
.hb-squeeze[aria-expanded="true"] span::after {
  transform: rotate(-45deg);
}
.hb-squeeze:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-squeeze span,
  .hb-squeeze span::before,
  .hb-squeeze span::after {
    transition: none;
  }
}

Bars into a back arrow

  • transform-origin
  • width transition
  • rotate

Click the icon to toggle it.

Instead of an X, the top and bottom bars shorten and pivot on their left ends to form an arrowhead, and the middle bar becomes the shaft. It reads as back, which suits menus that drill down into submenus.

View code
.hb-arrow {
  display: grid;
  place-items: center;
  width: 72px;
  height: 72px;
  padding: 0;
  border: 2px solid #080c14;
  border-radius: 50%;
  background: #fff;
  color: #080c14;
  cursor: pointer;
}
.hb-arrow span,
.hb-arrow span::before,
.hb-arrow span::after {
  display: block;
  width: 30px;
  height: 3px;
  border-radius: 3px;
  background: currentColor;
}
.hb-arrow span {
  position: relative;
}
.hb-arrow span::before,
.hb-arrow span::after {
  content: "";
  position: absolute;
  left: 0;
  transform-origin: 1.5px 50%;
  transition: top .3s ease, width .3s ease, transform .3s ease;
}
.hb-arrow span::before {
  top: -9px;
}
.hb-arrow span::after {
  top: 9px;
}
.hb-arrow[aria-expanded="true"] span::before {
  top: 0;
  width: 17px;
  transform: rotate(-45deg);
}
.hb-arrow[aria-expanded="true"] span::after {
  top: 0;
  width: 17px;
  transform: rotate(45deg);
}
.hb-arrow:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-arrow span::before,
  .hb-arrow span::after {
    transition: none;
  }
}

SVG lines with a drawn ring

  • inline SVG
  • stroke-dashoffset
  • transform-box

Click the icon to toggle it.

An inline SVG keeps the bars crisp at any size. On open, the outer lines rotate around the center of the viewBox, the middle line fades, and a circle draws itself around the X with stroke-dashoffset. transform-box: view-box is what makes the rotation pivot on the icon center.

View code
.hb-ring {
  padding: 4px;
  border: 0;
  border-radius: 50%;
  background: none;
  color: #f4f6fb;
  cursor: pointer;
}
.hb-ring svg {
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.5;
  stroke-linecap: round;
}
.hb-ring line,
.hb-ring circle {
  transform-box: view-box;
  transform-origin: 50% 50%;
  transition: transform .35s ease, opacity .2s ease, stroke-dashoffset .5s ease;
}
.hb-ring__circle {
  stroke: #ff6b3d;
  stroke-dasharray: 113.1;
  stroke-dashoffset: 113.1;
  transform: rotate(-90deg);
}
.hb-ring[aria-expanded="true"] .hb-ring__circle {
  stroke-dashoffset: 0;
}
.hb-ring[aria-expanded="true"] .hb-ring__top {
  transform: rotate(45deg) translateY(7px);
}
.hb-ring[aria-expanded="true"] .hb-ring__mid {
  opacity: 0;
}
.hb-ring[aria-expanded="true"] .hb-ring__bot {
  transform: rotate(-45deg) translateY(-7px);
}
.hb-ring:focus-visible {
  outline: 3px solid #ff6b3d;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-ring line,
  .hb-ring circle {
    transition: none;
  }
}

Staggered bars that line up

  • flexbox
  • :hover / :focus-visible
  • nth-child

Click the icon to toggle it.

Three bars of different lengths, right-aligned. Hover or keyboard focus evens them out as a hint that the control is live, and opening crosses the outer two. Using three child elements instead of pseudo-elements lets each bar change width on its own.

View code
.hb-stairs {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  justify-content: center;
  gap: 7px;
  width: 72px;
  height: 72px;
  padding: 0 20px;
  border: 0;
  border-radius: 50%;
  background: #ffd84d;
  color: #080c14;
  cursor: pointer;
}
.hb-stairs i {
  display: block;
  height: 3px;
  border-radius: 3px;
  background: currentColor;
  transition: width .25s ease, transform .3s ease, opacity .2s ease;
}
.hb-stairs i:nth-child(1) {
  width: 32px;
}
.hb-stairs i:nth-child(2) {
  width: 22px;
}
.hb-stairs i:nth-child(3) {
  width: 14px;
}
.hb-stairs:hover i,
.hb-stairs:focus-visible i,
.hb-stairs[aria-expanded="true"] i {
  width: 32px;
}
.hb-stairs[aria-expanded="true"] i:nth-child(1) {
  transform: translateY(10px) rotate(45deg);
}
.hb-stairs[aria-expanded="true"] i:nth-child(2) {
  opacity: 0;
  transform: scaleX(0);
}
.hb-stairs[aria-expanded="true"] i:nth-child(3) {
  transform: translateY(-10px) rotate(-45deg);
}
.hb-stairs:focus-visible {
  outline: 3px solid #080c14;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-stairs i {
    transition: none;
  }
}

Labeled pill with an ink fill

  • visible label
  • scaleX fill
  • isolation: isolate

Click the icon to toggle it.

Usability research keeps finding that a visible word beats a bare icon, so this one says Menu and never changes its name. A pseudo-element sweeps a dark fill across the pill when it opens, and a two-bar icon crosses at the same time.

View code
.hb-pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  padding: 0 24px 0 20px;
  overflow: hidden;
  isolation: isolate;
  border: 2px solid #080c14;
  border-radius: 999px;
  background: #fff;
  color: #080c14;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: color .3s ease;
}
.hb-pill::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: #080c14;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .35s ease;
}
.hb-pill__icon {
  position: relative;
  width: 20px;
  height: 10px;
}
.hb-pill__icon::before,
.hb-pill__icon::after {
  content: "";
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: top .25s ease, transform .25s ease;
}
.hb-pill__icon::before {
  top: 0;
}
.hb-pill__icon::after {
  top: 8px;
}
.hb-pill[aria-expanded="true"] {
  color: #fff;
}
.hb-pill[aria-expanded="true"]::before {
  transform: scaleX(1);
}
.hb-pill[aria-expanded="true"] .hb-pill__icon::before {
  top: 4px;
  transform: rotate(45deg);
}
.hb-pill[aria-expanded="true"] .hb-pill__icon::after {
  top: 4px;
  transform: rotate(-45deg);
}
.hb-pill:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-pill,
  .hb-pill::before,
  .hb-pill__icon::before,
  .hb-pill__icon::after {
    transition: none;
  }
}

Spring spin with linear() easing

  • linear() easing
  • cubic-bezier fallback
  • rotate

Click the icon to toggle it.

The whole icon spins half a turn and overshoots before it settles, like a spring. The bounce comes from the linear() easing function, which plots any curve as a list of points. The transition is declared twice, so browsers without linear() keep the cubic-bezier version.

View code
.hb-spring {
  display: grid;
  place-items: center;
  width: 72px;
  height: 72px;
  padding: 0;
  border: 0;
  border-radius: 22px;
  background: #1847f0;
  color: #fff;
  cursor: pointer;
}
.hb-spring span,
.hb-spring span::before,
.hb-spring span::after {
  display: block;
  width: 30px;
  height: 3px;
  border-radius: 3px;
  background: currentColor;
}
.hb-spring span {
  position: relative;
  transition: transform .7s cubic-bezier(.5, 1.6, .4, .8), background-color .15s ease;
  transition: transform .8s linear(0, .45 12%, 1.12 30%, .95 46%, 1.03 62%, .99 80%, 1), background-color .15s ease;
}
.hb-spring span::before,
.hb-spring span::after {
  content: "";
  position: absolute;
  left: 0;
  transition: top .2s ease, transform .2s ease;
}
.hb-spring span::before {
  top: -9px;
}
.hb-spring span::after {
  top: 9px;
}
.hb-spring[aria-expanded="true"] span {
  background-color: transparent;
  transform: rotate(180deg);
}
.hb-spring[aria-expanded="true"] span::before {
  top: 0;
  transform: rotate(45deg);
}
.hb-spring[aria-expanded="true"] span::after {
  top: 0;
  transform: rotate(-45deg);
}
.hb-spring:focus-visible {
  outline: 3px solid #080c14;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-spring span,
  .hb-spring span::before,
  .hb-spring span::after {
    transition: none;
  }
}

Hamburger menu panels

What opens behind the button. Each frame is phone-sized, so the mobile layout shows on any screen. The panels use the hidden attribute plus @starting-style, which keeps closed links out of the tab order while still animating in.

Off-canvas drawer with a backdrop

  • translate
  • allow-discrete
  • focus management

Open the menu inside the phone frame.

The drawer slides in from the left over a dimmed backdrop. Opening moves focus to the first link, and closing by the Close button, the backdrop or Escape sends it back to the toggle. On a real site, swap position: absolute for position: fixed so the drawer covers the viewport.

View code
.hb-drawer a {
  color: inherit;
  text-decoration: none;
}
.hb-drawer__bar {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 16px;
  border-bottom: 1px solid #ece5d8;
  background: #fbf8f3;
  color: #2b2622;
}
.hb-drawer__logo {
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 700;
}
.hb-drawer__toggle {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 0 16px 0 14px;
  border: 1.5px solid #d8cfbf;
  border-radius: 999px;
  background: #fff;
  color: #2b2622;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.hb-drawer__toggle span,
.hb-drawer__toggle span::before {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
}
.hb-drawer__toggle span {
  position: relative;
  margin-top: -5px;
}
.hb-drawer__toggle span::before {
  content: "";
  position: absolute;
  top: 6px;
  width: 11px;
}
.hb-drawer__backdrop {
  position: absolute;
  inset: 0;
  z-index: 3;
  background: rgba(30, 24, 18, .45);
  transition: opacity .3s ease, display .3s allow-discrete;
}
.hb-drawer__backdrop[hidden] {
  display: none;
  opacity: 0;
}
.hb-drawer__panel {
  position: absolute;
  inset: 0 auto 0 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  width: min(300px, 84%);
  padding: 18px 24px 26px;
  background: #fbf8f3;
  color: #2b2622;
  box-shadow: 20px 0 50px rgba(30, 24, 18, .25);
  transition: translate .35s cubic-bezier(.2, .8, .2, 1), display .35s allow-discrete;
}
.hb-drawer__panel[hidden] {
  display: none;
  translate: -100% 0;
}
@starting-style {
  .hb-drawer__backdrop {
    opacity: 0;
  }
  .hb-drawer__panel {
    translate: -100% 0;
  }
}
.hb-drawer__close {
  align-self: flex-end;
  min-height: 44px;
  padding: 0 4px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
}
.hb-drawer__panel ul {
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.hb-drawer__panel li a {
  display: block;
  padding: 10px 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 600;
}
.hb-drawer__panel li a:hover,
.hb-drawer__panel li a:focus-visible {
  color: #b5562c;
}
.hb-drawer .hb-drawer__book {
  margin-top: auto;
  padding: 14px 18px;
  border-radius: 999px;
  background: #2b2622;
  color: #fbf8f3;
  font-weight: 700;
  text-align: center;
}
.hb-drawer button:focus-visible,
.hb-drawer a:focus-visible {
  outline: 2px solid #b5562c;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-drawer__backdrop,
  .hb-drawer__panel {
    transition: none;
  }
}

Full-screen overlay with staggered links

  • @starting-style on children
  • transition-delay
  • custom property --i

Open the menu inside the phone frame.

A dark overlay with oversized serif links that rise in one after another. Each list item carries its position in a --i custom property, and transition-delay multiplies it by 60ms. @starting-style applies to the links too, because they had no computed style while the overlay was hidden.

View code
.hb-over {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 12px 20px;
  background: #f4f1ea;
}
.hb-over a {
  color: inherit;
  text-decoration: none;
}
.hb-over__logo {
  position: relative;
  z-index: 3;
  color: #101010;
  font-size: 18px;
  font-weight: 800;
  letter-spacing: -.02em;
  transition: color .3s ease;
}
.hb-over__toggle {
  position: relative;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: #101010;
  cursor: pointer;
  transition: color .3s ease;
}
.hb-over__toggle span,
.hb-over__toggle span::before {
  display: block;
  width: 26px;
  height: 2px;
  background: currentColor;
  transition: transform .3s ease;
}
.hb-over__toggle span {
  position: relative;
  transform: translateY(-4px);
}
.hb-over__toggle span::before {
  content: "";
  position: absolute;
  left: 0;
  transform: translateY(8px);
}
.hb-over:has(.hb-over__toggle[aria-expanded="true"]) .hb-over__logo,
.hb-over__toggle[aria-expanded="true"] {
  color: #f4f1ea;
}
.hb-over__toggle[aria-expanded="true"] span {
  transform: rotate(45deg);
}
.hb-over__toggle[aria-expanded="true"] span::before {
  transform: rotate(-90deg);
}
.hb-over__menu {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 80px 30px 36px;
  background: #101010;
  color: #f4f1ea;
  transition: opacity .3s ease, display .3s allow-discrete;
}
.hb-over__menu[hidden] {
  display: none;
  opacity: 0;
}
.hb-over__menu ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-over__menu li {
  transition: opacity .4s ease, translate .5s cubic-bezier(.2, .8, .2, 1);
  transition-delay: calc(var(--i) * 60ms);
}
.hb-over__menu li a {
  display: inline-block;
  padding: 4px 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 44px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.02em;
  transition: color .2s ease, translate .2s ease;
}
.hb-over__menu li a:hover,
.hb-over__menu li a:focus-visible {
  color: #ff6b3d;
  translate: 8px 0;
}
.hb-over__menu p {
  margin: auto 0 0;
  color: #9a968d;
  font-size: 14px;
}
@starting-style {
  .hb-over__menu {
    opacity: 0;
  }
  .hb-over__menu li {
    opacity: 0;
    translate: 0 24px;
  }
}
.hb-over__toggle:focus-visible,
.hb-over a:focus-visible {
  outline: 2px solid #ff6b3d;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-over__menu,
  .hb-over__menu li,
  .hb-over__toggle span,
  .hb-over__toggle span::before {
    transition: none;
  }
}

Circular reveal from the button

  • clip-path: circle()
  • allow-discrete
  • z-index layering

Open the menu inside the phone frame.

The panel grows out of the toggle as an expanding circle. clip-path: circle() starts at radius 0 centered on the button and transitions to 150%, which is enough to cover the farthest corner. The toggle sits one layer above the panel, so it stays tappable as the close button.

View code
.hb-reveal {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px 12px 20px;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
}
.hb-reveal a {
  color: inherit;
  text-decoration: none;
}
.hb-reveal__logo {
  color: #080c14;
  font-size: 19px;
  font-weight: 800;
  font-style: italic;
}
.hb-reveal__toggle {
  position: relative;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #1847f0;
  color: #fff;
  cursor: pointer;
  transition: background-color .3s ease, color .3s ease;
}
.hb-reveal__toggle span,
.hb-reveal__toggle span::before,
.hb-reveal__toggle span::after {
  display: block;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .3s ease, background-color .3s ease;
}
.hb-reveal__toggle span {
  position: relative;
}
.hb-reveal__toggle span::before,
.hb-reveal__toggle span::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-reveal__toggle span::before {
  transform: translateY(-6px);
}
.hb-reveal__toggle span::after {
  transform: translateY(6px);
}
.hb-reveal__toggle[aria-expanded="true"] {
  background: #fff;
  color: #1847f0;
}
.hb-reveal__toggle[aria-expanded="true"] span {
  background-color: transparent;
}
.hb-reveal__toggle[aria-expanded="true"] span::before {
  transform: rotate(45deg);
}
.hb-reveal__toggle[aria-expanded="true"] span::after {
  transform: rotate(-45deg);
}
.hb-reveal__menu {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  padding: 92px 28px 30px;
  background: #1847f0;
  color: #fff;
  clip-path: circle(150% at calc(100% - 38px) 34px);
  transition: clip-path .55s cubic-bezier(.6, 0, .3, 1), display .55s allow-discrete;
}
.hb-reveal__menu[hidden] {
  display: none;
  clip-path: circle(0 at calc(100% - 38px) 34px);
}
@starting-style {
  .hb-reveal__menu {
    clip-path: circle(0 at calc(100% - 38px) 34px);
  }
}
.hb-reveal__menu ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-reveal__menu li a {
  display: block;
  padding: 8px 0;
  font-size: 34px;
  font-weight: 800;
  letter-spacing: -.03em;
}
.hb-reveal__menu li a:hover,
.hb-reveal__menu li a:focus-visible {
  color: #ffd84d;
}
.hb-reveal .hb-reveal__cta {
  margin-top: auto;
  padding: 15px 18px;
  border-radius: 14px;
  background: #ffd84d;
  color: #080c14;
  font-weight: 700;
  text-align: center;
}
.hb-reveal__toggle:focus-visible,
.hb-reveal a:focus-visible {
  outline: 2px solid #ffd84d;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-reveal__menu {
    transition: none;
  }
}

Bottom sheet in the thumb zone

  • translate: 0 100%
  • grid tiles
  • backdrop

Open the menu inside the phone frame.

The toggle floats at the bottom of the screen, where a thumb reaches it without stretching, and the menu rises as a sheet of large tiles. The button stays above the sheet, so the same control closes it, and a tap on the dimmed page works too.

View code
.hb-sheet__toggle {
  position: absolute;
  left: 50%;
  bottom: 22px;
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 52px;
  padding: 0 24px 0 20px;
  border: 0;
  border-radius: 999px;
  background: #080c14;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  box-shadow: 0 12px 30px rgba(8, 12, 20, .3);
  translate: -50% 0;
  cursor: pointer;
}
.hb-sheet__toggle span,
.hb-sheet__toggle span::before {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .3s ease;
}
.hb-sheet__toggle span {
  position: relative;
  transform: translateY(-3px);
}
.hb-sheet__toggle span::before {
  content: "";
  position: absolute;
  left: 0;
  transform: translateY(6px);
}
.hb-sheet__toggle[aria-expanded="true"] span {
  transform: rotate(45deg);
}
.hb-sheet__toggle[aria-expanded="true"] span::before {
  transform: rotate(-90deg);
}
.hb-sheet__backdrop {
  position: absolute;
  inset: 0;
  z-index: 3;
  background: rgba(8, 12, 20, .4);
  transition: opacity .3s ease, display .3s allow-discrete;
}
.hb-sheet__backdrop[hidden] {
  display: none;
  opacity: 0;
}
.hb-sheet__panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 4;
  padding: 10px 18px 98px;
  border-radius: 26px 26px 0 0;
  background: #fff;
  box-shadow: 0 -20px 50px rgba(8, 12, 20, .18);
  transition: translate .4s cubic-bezier(.2, .8, .2, 1), display .4s allow-discrete;
}
.hb-sheet__panel[hidden] {
  display: none;
  translate: 0 100%;
}
@starting-style {
  .hb-sheet__backdrop {
    opacity: 0;
  }
  .hb-sheet__panel {
    translate: 0 100%;
  }
}
.hb-sheet__handle {
  display: block;
  width: 40px;
  height: 5px;
  margin: 0 auto 16px;
  border-radius: 5px;
  background: #d5d9e4;
}
.hb-sheet__panel ul {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-sheet__panel a {
  display: grid;
  justify-items: center;
  gap: 10px;
  padding: 16px 6px 14px;
  border-radius: 18px;
  background: #f2f4fa;
  color: #080c14;
  font-size: 14px;
  font-weight: 600;
  text-decoration: none;
  transition: background-color .2s ease;
}
.hb-sheet__panel a::before {
  content: "";
  width: 30px;
  height: 30px;
  border-radius: 10px;
  background: var(--c);
}
.hb-sheet__panel a:hover,
.hb-sheet__panel a:focus-visible {
  background: #e4e8f3;
}
.hb-sheet button:focus-visible,
.hb-sheet a:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-sheet__toggle span,
  .hb-sheet__toggle span::before,
  .hb-sheet__backdrop,
  .hb-sheet__panel {
    transition: none;
  }
}

Push menu that shrinks the page

  • :has()
  • transform: scale()
  • visibility transition

Open the menu inside the phone frame.

Instead of covering the page, the menu sits behind it, and opening pushes the page to the right and shrinks it into a card. :has() reads the toggle state, so the script only sets aria-expanded. visibility: hidden keeps the links out of the tab order while the menu is closed.

View code
.hb-push {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: #14161f;
}
.hb-push__nav {
  position: absolute;
  inset: 0 auto 0 0;
  width: 62%;
  padding: 84px 26px;
  visibility: hidden;
  opacity: 0;
  translate: -24px 0;
  transition: opacity .3s ease, translate .3s ease, visibility 0s .3s;
}
.hb-push__nav ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-push__nav a {
  display: block;
  padding: 11px 0;
  color: #9aa1b5;
  font-size: 19px;
  font-weight: 600;
  text-decoration: none;
}
.hb-push__nav a[aria-current="page"],
.hb-push__nav a:hover,
.hb-push__nav a:focus-visible {
  color: #fff;
}
.hb-push__page {
  position: absolute;
  inset: 0;
  background: #fff;
  transform-origin: left center;
  transition: transform .45s cubic-bezier(.2, .8, .2, 1), border-radius .45s ease;
}
.hb-push__bar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid #e6e8ef;
  color: #080c14;
  font-size: 17px;
}
.hb-push__toggle {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: #f2f4fa;
  color: #080c14;
  cursor: pointer;
}
.hb-push__toggle span,
.hb-push__toggle span::before,
.hb-push__toggle span::after {
  display: block;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
}
.hb-push__toggle span {
  position: relative;
}
.hb-push__toggle span::before,
.hb-push__toggle span::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-push__toggle span::before {
  top: -6px;
}
.hb-push__toggle span::after {
  top: 6px;
  width: 12px;
}
.hb-push__content {
  display: grid;
  gap: 14px;
  padding: 18px 16px;
}
.hb-push__content div {
  height: 92px;
  border-radius: 14px;
  background: #eef1f6;
}
.hb-push__content div:first-child {
  height: 140px;
  background: #dfe6ff;
}
.hb-push:has(.hb-push__toggle[aria-expanded="true"]) .hb-push__nav {
  visibility: visible;
  opacity: 1;
  translate: 0 0;
  transition: opacity .35s .1s ease, translate .35s .1s ease, visibility 0s;
}
.hb-push:has(.hb-push__toggle[aria-expanded="true"]) .hb-push__page {
  border-radius: 26px;
  transform: translateX(62%) scale(.84);
  cursor: pointer;
}
.hb-push button:focus-visible,
.hb-push a:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-push__nav,
  .hb-push__page {
    transition: none;
  }
}

Combo navigation with a More button

  • visible links + toggle
  • aria-current
  • two-column grid

Open the menu inside the phone frame.

The pattern the usability research favors over a fully hidden menu: the three most used sections stay visible as tabs, and a labeled More button holds the rest. Fewer people miss navigation they can see, and the toggle still keeps the header short.

View code
.hb-combo {
  position: relative;
  z-index: 2;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
}
.hb-combo a {
  color: inherit;
  text-decoration: none;
}
.hb-combo__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 18px 8px;
}
.hb-combo__logo {
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 700;
}
.hb-combo__cta {
  font-size: 14px;
  font-weight: 600;
}
.hb-combo__nav {
  display: flex;
  align-items: center;
  padding: 0 10px 0 18px;
}
.hb-combo__links {
  display: flex;
  gap: 20px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-combo__links a {
  display: block;
  padding: 12px 0;
  border-bottom: 3px solid transparent;
  color: #3b4256;
  font-size: 15px;
  font-weight: 600;
}
.hb-combo__links a[aria-current="page"] {
  border-bottom-color: #c8102e;
  color: #080c14;
}
.hb-combo__more {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  margin-left: auto;
  padding: 0 10px;
  border: 0;
  background: none;
  color: #080c14;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.hb-combo__more span,
.hb-combo__more span::before,
.hb-combo__more span::after {
  display: block;
  width: 14px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .25s ease, background-color .25s ease;
}
.hb-combo__more span {
  position: relative;
}
.hb-combo__more span::before,
.hb-combo__more span::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-combo__more span::before {
  transform: translateY(-5px);
}
.hb-combo__more span::after {
  transform: translateY(5px);
}
.hb-combo__more[aria-expanded="true"] span {
  background-color: transparent;
}
.hb-combo__more[aria-expanded="true"] span::before {
  transform: rotate(45deg);
}
.hb-combo__more[aria-expanded="true"] span::after {
  transform: rotate(-45deg);
}
.hb-combo__extra {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 18px;
  margin: 0;
  padding: 8px 18px 14px;
  list-style: none;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
  box-shadow: 0 24px 40px rgba(8, 12, 20, .12);
  transition: opacity .2s ease, translate .2s ease, display .2s allow-discrete;
}
.hb-combo__extra[hidden] {
  display: none;
  opacity: 0;
  translate: 0 -6px;
}
@starting-style {
  .hb-combo__extra {
    opacity: 0;
    translate: 0 -6px;
  }
}
.hb-combo__extra a {
  display: block;
  padding: 13px 0;
  border-bottom: 1px solid #eef0f5;
  font-size: 16px;
  font-weight: 600;
}
.hb-combo button:focus-visible,
.hb-combo a:focus-visible {
  outline: 2px solid #c8102e;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-combo__extra,
  .hb-combo__more span,
  .hb-combo__more span::before,
  .hb-combo__more span::after {
    transition: none;
  }
}

Hamburger menus without JavaScript

The three script-free mechanisms side by side: the checkbox hack, details and summary, and the popover attribute. The guide below the examples explains what each one costs in keyboard and screen reader support.

Checkbox hack

  • :checked
  • general sibling ~
  • label for

Open the menu inside the phone frame.

A visually hidden checkbox stores the state, its label draws the icon, and :checked ~ shows the menu. It needs no script, but Space is the only key that toggles it, Escape does nothing, and screen readers announce a checkbox instead of a menu control. Fine for a prototype, not for production.

View code
.hb-check {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 12px 20px;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
}
.hb-check a {
  color: #080c14;
  text-decoration: none;
}
.hb-check__logo {
  font-size: 19px;
  font-weight: 800;
}
.hb-check__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}
.hb-check__label {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: #f2f4fa;
  color: #080c14;
  cursor: pointer;
}
.hb-check__name {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.hb-check__label span:first-child,
.hb-check__label span:first-child::before,
.hb-check__label span:first-child::after {
  display: block;
  width: 20px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .25s ease, background-color .25s ease;
}
.hb-check__label span:first-child {
  position: relative;
}
.hb-check__label span:first-child::before,
.hb-check__label span:first-child::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-check__label span:first-child::before {
  transform: translateY(-6px);
}
.hb-check__label span:first-child::after {
  transform: translateY(6px);
}
.hb-check__input:checked + .hb-check__label span:first-child {
  background-color: transparent;
}
.hb-check__input:checked + .hb-check__label span:first-child::before {
  transform: rotate(45deg);
}
.hb-check__input:checked + .hb-check__label span:first-child::after {
  transform: rotate(-45deg);
}
.hb-check__input:focus-visible + .hb-check__label {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
.hb-check__menu {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  display: none;
  padding: 6px 20px 14px;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
  box-shadow: 0 24px 40px rgba(8, 12, 20, .12);
}
.hb-check__input:checked ~ .hb-check__menu {
  display: block;
}
.hb-check__menu ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-check__menu a {
  display: block;
  padding: 14px 0;
  border-bottom: 1px solid #eef0f5;
  font-size: 17px;
  font-weight: 600;
}
.hb-check__menu li:last-child a {
  border-bottom: 0;
}
.hb-check a:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}

details and summary

  • details[open]
  • summary
  • list-style: none

Open the menu inside the phone frame.

The native disclosure widget opens and closes with Enter, Space or a tap, no script involved. The default triangle is removed, the summary is styled as the button, and details[open] drives the X. Some screen reader and voice control pairings still skip its state, which the guide covers below.

View code
.hb-details {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 12px 20px;
  border-bottom: 1px solid #dfe8dc;
  background: #f4f8f2;
}
.hb-details a {
  color: #16301f;
  text-decoration: none;
}
.hb-details__logo {
  font-size: 19px;
  font-weight: 800;
}
.hb-details__menu summary {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 0 16px;
  border-radius: 999px;
  background: #16301f;
  color: #f4f8f2;
  font-size: 14px;
  font-weight: 700;
  list-style: none;
  cursor: pointer;
}
.hb-details__menu summary::-webkit-details-marker {
  display: none;
}
.hb-details__menu summary span,
.hb-details__menu summary span::before,
.hb-details__menu summary span::after {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .25s ease, background-color .25s ease;
}
.hb-details__menu summary span {
  position: relative;
}
.hb-details__menu summary span::before,
.hb-details__menu summary span::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-details__menu summary span::before {
  transform: translateY(-5px);
}
.hb-details__menu summary span::after {
  transform: translateY(5px);
}
.hb-details__menu[open] summary span {
  background-color: transparent;
}
.hb-details__menu[open] summary span::before {
  transform: rotate(45deg);
}
.hb-details__menu[open] summary span::after {
  transform: rotate(-45deg);
}
.hb-details__menu summary:focus-visible {
  outline: 2px solid #3f8f5a;
  outline-offset: 2px;
}
.hb-details__menu nav {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  padding: 6px 20px 14px;
  border-bottom: 1px solid #dfe8dc;
  background: #f4f8f2;
  box-shadow: 0 24px 40px rgba(22, 48, 31, .12);
}
.hb-details__menu ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.hb-details__menu nav a {
  display: block;
  padding: 14px 0;
  border-bottom: 1px solid #dfe8dc;
  font-size: 17px;
  font-weight: 600;
}
.hb-details__menu li:last-child a {
  border-bottom: 0;
}
.hb-details a:focus-visible {
  outline: 2px solid #3f8f5a;
  outline-offset: 2px;
}

Popover drawer with light dismiss

  • popover / popovertarget
  • :popover-open
  • ::backdrop

Open the menu inside the phone frame.

A button with popovertarget opens the nav, and the browser handles Escape, clicks outside the panel and the open state for screen readers. Open it and the drawer slides in from the edge of the real window, not the phone frame, because popovers render in the top layer where no parent can clip them.

View code
.hb-pop {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 12px 20px;
  border-bottom: 1px solid #e6e8ef;
  background: #fff;
}
.hb-pop a,
.hb-pop__menu a {
  color: inherit;
  text-decoration: none;
}
.hb-pop__logo {
  color: #080c14;
  font-size: 19px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: .08em;
}
.hb-pop__toggle {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: #0b0f1a;
  color: #fff;
  cursor: pointer;
}
.hb-pop__toggle span,
.hb-pop__toggle span::before,
.hb-pop__toggle span::after {
  display: block;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
}
.hb-pop__toggle span {
  position: relative;
}
.hb-pop__toggle span::before,
.hb-pop__toggle span::after {
  content: "";
  position: absolute;
  left: 0;
}
.hb-pop__toggle span::before {
  top: -6px;
}
.hb-pop__toggle span::after {
  top: 6px;
}
.hb-pop__menu {
  inset: 0 0 0 auto;
  width: min(320px, 86vw);
  height: 100dvh;
  max-height: none;
  margin: 0;
  padding: 18px 28px 28px;
  border: 0;
  background: #0b0f1a;
  color: #f4f6fb;
  translate: 100% 0;
  transition: translate .35s cubic-bezier(.2, .8, .2, 1), overlay .35s allow-discrete, display .35s allow-discrete;
}
.hb-pop__menu:popover-open {
  display: flex;
  flex-direction: column;
  translate: 0 0;
}
@starting-style {
  .hb-pop__menu:popover-open {
    translate: 100% 0;
  }
}
.hb-pop__menu::backdrop {
  background: rgba(8, 12, 20, 0);
  transition: background-color .35s ease, overlay .35s allow-discrete, display .35s allow-discrete;
}
.hb-pop__menu:popover-open::backdrop {
  background: rgba(8, 12, 20, .5);
}
@starting-style {
  .hb-pop__menu:popover-open::backdrop {
    background: rgba(8, 12, 20, 0);
  }
}
.hb-pop__close {
  align-self: flex-end;
  min-height: 44px;
  padding: 0 4px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
}
.hb-pop__menu ul {
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
}
.hb-pop__menu li a {
  display: block;
  padding: 13px 0;
  border-bottom: 1px solid #1f2637;
  font-size: 22px;
  font-weight: 700;
}
.hb-pop__menu li a:hover,
.hb-pop__menu li a:focus-visible {
  color: #7ea0ff;
}
.hb-pop__toggle:focus-visible,
.hb-pop__menu button:focus-visible,
.hb-pop__menu a:focus-visible {
  outline: 2px solid #7ea0ff;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .hb-pop__menu,
  .hb-pop__menu::backdrop {
    transition: none;
  }
}

What is a CSS hamburger menu?

Most people mean the three-line icon in a phone header that opens the site’s links. Built with HTML and CSS, with or without JavaScript, it collapses the main navigation on small screens and brings it back when tapped.

Every version has an icon drawn with CSS or SVG, a toggle control that flips the open and closed state, a navigation panel holding the links, and a breakpoint rule that sets the screen width where the icon replaces the inline links.

Below that width, the menu swaps a visible row of links in the site navigation for a single button. Above it, the links return and the icon disappears.

You’ll also hear it called a menu icon, nav toggle, mobile menu, off-canvas menu or navigation drawer. The name usually follows how the panel opens, not how the icon looks.

It is not a mega menu and not a tab bar. The hamburger is only the trigger. A dropdown can sit behind it, or a slide-in drawer, and sometimes a full-screen overlay.

For the wider interface pattern, see this explainer on the hamburger menu in UI design.

How to draw the hamburger icon and animate it into an X

One empty span and its two pseudo-elements are enough for the icon. To get the X, rotate the outer bars 45 degrees and hide the middle one. It all runs on CSS transform and transition, so the only script involved is the toggle itself.

Building the three bars

There are several ways to draw the icon, and they trade markup size against animation freedom.

  • The span with ::before and ::after is where I’d start. The span is the middle bar and the pseudo-elements are the top and bottom bars, so there’s no extra markup and each bar moves on its own.
  • A single element with aspect-ratio and a linear-gradient background can paint all 3 bars. It’s the lightest markup, but the bars share one box, so they cannot rotate separately.
  • Inline SVG, with lines or paths, stays sharp at any size. It also takes the most markup.
  • The trigram character U+2630 works as button text and needs no CSS at all. The glyph depends on the font, though, and it can never become an X.

Inline SVG is the right pick when the icon has to match an existing icon set. A guide to placing SVG in HTML covers the embedding options.

<button class="menu-toggle" type="button" aria-expanded="false" aria-label="Menu">
  <span class="bars"></span>
</button>
.menu-toggle { --bar-w: 24px; --bar-h: 2px; --bar-gap: 7px; }

.bars,
.bars::before,
.bars::after {
  display: block;
  width: var(--bar-w);
  height: var(--bar-h);
  border-radius: 2px;
  background: currentColor;
  transition: transform 250ms ease, top 250ms ease, background-color 250ms ease;
}
.bars { position: relative; }
.bars::before,
.bars::after { content: ""; position: absolute; left: 0; }
.bars::before { top: calc(var(--bar-gap) * -1); }
.bars::after  { top: var(--bar-gap); }

A 7px offset on a 2px bar leaves a 5px gap between bars. Keeping size and gap in custom properties means one edit resizes the whole icon.

Turning the bars into an X

Here is the open state.

.menu-toggle[aria-expanded="true"] .bars { background: transparent; }
.menu-toggle[aria-expanded="true"] .bars::before { top: 0; transform: rotate(45deg); }
.menu-toggle[aria-expanded="true"] .bars::after  { top: 0; transform: rotate(-45deg); }

The middle bar goes transparent while both outer bars slide to the center line and cross. Because the styling hangs off aria-expanded, the icon and the announced state stay in step. One attribute drives both.

For timing, 200ms to 300ms is enough. Past 400ms the icon starts to lag behind the tap.

The change is a small micro-interaction, and its job is to confirm the tap registered before the panel finishes opening.

The gradient-only icon is the one that can’t morph this way. Swap its background for a second gradient that draws a close icon and you get a swap instead of an animation.

Choosing a toggle mechanism for the CSS hamburger menu

For a production site I’d use a button with a few lines of JavaScript. The popover attribute is the native alternative when the audience runs current browsers, and the checkbox hack suits prototypes only.

MechanismNeeds JavaScriptOpen state for screen readersSupport status
Checkbox hackNoAnnounced as a checkboxAll modern browsers
Button with JavaScriptYes, a short scriptaria-expanded set by the scriptAll modern browsers
details and summaryNoNative, with screen reader gapsBaseline widely available
Popover attributeNoImplicit aria-expanded on the buttonBaseline newly available

The checkbox hack uses a hidden checkbox, a label styled as the icon and the :checked state with a sibling combinator to show and hide the list. No script is needed.

In our keyboard test of the checkbox example above (Chromium, 390px viewport), Space opened the menu. Enter did nothing, and Escape did not close it.

The checkbox has to come before the list and share its parent, because the general sibling combinator only looks forward. Screen readers announce a checkbox, not a menu control, and since nothing in CSS can update aria-expanded, the open state is never reported.

With a real button, a click flips aria-expanded and the list’s hidden state. Enter and Space work because the browser treats them as clicks on a button.

W3C’s WAI-ARIA Authoring Practices builds its disclosure navigation example the same way, with aria-controls and aria-expanded on a button.

That example skips the ARIA menu role on purpose, because the role demands composite-widget focus handling that ordinary site navigation does not need.

GOV.UK Frontend v4.3.0 moved the header’s mobile menu to the hidden attribute, and the Menu button starts hidden so visitors whose script fails to load never see a dead control.

One failure mode to watch. If you hide the list in CSS and rely on script to open it, a failed script leaves visitors with no links at all. The build in the next section avoids that by letting the script do the hiding.

The native details and summary widget opens and closes without script. Baseline, the cross-browser availability label from the web-features project, lists it as widely available since July 2022.

Cloud Four tested it as a burger menu. VoiceOver on iOS with Safari did not announce whether the menu was expanded or collapsed, and Dragon Naturally Speaking could not activate it by voice.

Commenters on that post counter that the gaps are general assistive technology support for details, not something specific to menus. The cause differs, but the result for the visitor does not.

The popover attribute works differently again. A button with popovertarget opens any element marked popover, with no script. MDN documents several behaviors that matter for a menu. In the default auto mode, Escape and a click outside both close it (light dismiss). The button gets an implicit aria-expanded and aria-details link to the popover, the popover lands in a logical spot in the keyboard focus order when it opens, and top layer rendering means parent position and overflow rules cannot clip it.

Browser support has a dated catch. web.dev announced the Popover API as Baseline in April 2024, then corrected itself. The feature entered Baseline on January 27, 2025, because light dismiss failed on some mobile browsers.

MDN pages carry both labels in different versions, so check the current Baseline badge before relying on either date.

My rule of thumb is to pick the popover attribute when every browser in your analytics is current. Otherwise use the button, which works everywhere and costs a short script.

How to build a responsive CSS hamburger menu step by step

Build the narrow layout first, let a short script collapse the list, then add one media query that restores the inline links on wide screens.

  1. Write the markup: a header with a nav landmark, a button that starts hidden, and the list of links.
  2. Style the narrow layout as the default, with no media query.
  3. Let the script reveal the button and collapse the list, so visitors without JavaScript keep every link.
  4. Toggle aria-expanded and the list’s hidden state when the button is clicked.
  5. Add a media query that hides the button and lays the links out in a row above the breakpoint.
  6. Style the open panel, then resize the window and test with the keyboard.
<header class="site-header">
  <a class="logo" href="/">Brand</a>
  <nav aria-label="Main">
    <button class="menu-toggle" type="button" aria-expanded="false"
        aria-controls="site-menu" aria-label="Menu" hidden>
      <span class="bars"></span>
    </button>
    <ul id="site-menu" class="site-menu">
      <li><a href="/products/">Products</a></li>
      <li><a href="/pricing/">Pricing</a></li>
      <li><a href="/contact/">Contact</a></li>
    </ul>
  </nav>
</header>
.site-menu { list-style: none; margin: 0; padding: 0; }

@media (min-width: 48em) {
  .menu-toggle { display: none; }
  .site-menu { display: flex; gap: 1.5rem; }
}
const toggle = document.querySelector('.menu-toggle');
const menu = document.getElementById('site-menu');

toggle.hidden = false;
menu.hidden = true;

toggle.addEventListener('click', () => {
  const open = toggle.getAttribute('aria-expanded') === 'true';
  toggle.setAttribute('aria-expanded', String(!open));
  menu.hidden = open;
});

A media query decides when the toggle gives way to the inline links. The author rule display: flex beats the browser’s display: none for the hidden attribute, so the list reappears above the breakpoint even though the script set hidden.

Writing the narrow layout as the default and adding wide rules with min-width is mobile-first design. The desktop-first alternative uses max-width and works, but every narrow-screen rule has to undo a desktop one.

This script covers click, Enter and Space. Escape handling and focus return belong with the accessibility rules, so add them before shipping.

Choosing the breakpoint

48em equals 768px at the default 16px font size, which is the usual starting point. The right value is the width where your links stop fitting on one line.

The DWP Design System header, adapted from GOV.UK, follows the same idea. Its Menu button controls a navigation panel that starts hidden, and a noscript copy of the links keeps the full navigation visible when JavaScript does not run.

To find your own number, drag the window from wide to narrow and note the width where the last link drops to a second row. Set the breakpoint just above that, so no band of widths shows a wrapped header. Retest after adding a link or translating labels, since longer labels move the breakpoint.

A header with seven links and a search field often runs out of room well before 768px. A fixed 768px leaves a broken tablet band whenever the links wrap at 900px.

Dropdown, off-canvas or full-screen overlay panels

A dropdown suits a short list. Long lists, or ones with submenus, belong in an off-canvas panel, and a full-screen overlay only makes sense when the toggle is the main way around the site. All of them sit above page content, and the larger two should also stop the page scrolling underneath.

For layering, give the header position: relative and a z-index above the content, and keep the panel inside the header so it inherits that layer.

One class on the root element handles the scroll lock. Swap the click handler from the build for a shared function, and unlock when the viewport crosses the breakpoint.

const root = document.documentElement;
const wide = matchMedia('(min-width: 48em)');

function setOpen(open) {
  toggle.setAttribute('aria-expanded', String(open));
  menu.hidden = !open;
  root.classList.toggle('menu-open', open);
}

toggle.addEventListener('click', () => {
  setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});

wide.addEventListener('change', (e) => {
  if (e.matches) setOpen(false);
});
.menu-open { overflow: hidden; }

Without the change listener, resizing past the breakpoint with the panel open leaves the page locked.

Dropdown

The panel drops from the header edge and covers part of the page. It needs the least CSS of the lot.

Position it absolute, with top at 100% of the header and left and right at 0. Let content set the height, and add max-height with overflow-y: auto for long lists. Closing happens on a second tap of the toggle, a tap on a link, or Escape.

The mechanism is the one behind other CSS dropdown menus, with a button as the trigger instead of hover.

The page stays scrollable behind a short panel, so the scroll lock is optional here.

Off-canvas panel

Sliding a panel in from a hidden state works in CSS alone since August 2024, when @starting-style and transition-behavior: allow-discrete became Baseline newly available (web.dev, August 8, 2024).

The build hides the list with the hidden attribute, which means display: none, so nothing can transition. Add display to the transition list with allow-discrete, and give the entry state inside @starting-style.

@media (max-width: 47.99em) {
  .site-menu {
    position: fixed;
    inset: 0 auto 0 0;
    width: min(20rem, 85vw);
    background: #fff;
    transition: transform 300ms ease, display 300ms allow-discrete;
  }
  .site-menu[hidden] { transform: translateX(-100%); }

  @starting-style {
    .site-menu { transform: translateX(-100%); }
  }
}

This is the drawer pattern from UI design, and the panel width stays capped so a strip of the page remains visible.

A panel parked off-screen with transform alone stays in the tab order, and keyboard users tab into links they cannot see. The hidden attribute avoids that, because display: none removes the links from the tab order.

Full-screen overlay

A full-screen overlay is a fixed panel with no gaps around it.

.site-menu {
  position: fixed;
  inset: 0;
  overflow-y: auto;
  background: #fff;
}

Prefer inset: 0. If you need an explicit height, use 100dvh, because MDN documents vh as equal to the large viewport, with browser toolbars retracted, so 100vh runs past the visible screen while a toolbar shows.

A plain fixed panel serves one list of links for both layouts, but the focus handling is yours to write. A modal dialog opened with showModal() gets the top layer, an inert page, Escape to close and initial focus from the browser (MDN), but a closed dialog cannot double as the inline link row on wide screens.

Accessibility requirements for a CSS hamburger menu

Start with a real button that has one constant name, then keep aria-expanded and aria-controls in sync with the panel. Escape should close it and return focus to the button. Add a visible focus ring, a target of at least 24 by 24 CSS pixels, and reduced motion handling.

Keyboard and screen reader behavior

A native button gives you Enter and Space with no extra code. Give it one constant label, such as “Menu”. The aria-expanded value flips with the panel, and aria-controls points at the list’s id. Escape closes the panel and sends focus back to the toggle, as keyboard users expect from modal dialogs (MDN). The focus ring is a :focus-visible outline, and it stays.

Both attributes come from ARIA, and W3C’s APG disclosure navigation example sets aria-expanded to false for a hidden container and to true for a visible one.

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
    setOpen(false);
    toggle.focus();
  }
});
.menu-toggle:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

Sources split on the label. A widely shared uxdesign.cc guide says to switch aria-label from “Open menu” to “Close menu”, while W3C’s APG button pattern says a toggle’s label must not change when its state changes.

The APG rule is written for aria-pressed toggles, and the same logic holds for aria-expanded: a changing label plus a changing state announces the same thing twice. Keep one label.

Target size

CriterionLevelMinimum target
WCAG 2.2 SC 2.5.8, Target Size (Minimum)AA24 by 24 CSS pixels
WCAG SC 2.5.5, Target Size (Enhanced)AAA44 by 44 CSS pixels

W3C’s Understanding document for 2.5.8 lets a smaller target pass when a 24px circle centered on it touches no other target.

The icon is 24px wide with 2px bars, so the button is the target, not the icon. Pad the button to 44 by 44 and it passes both criteria.

.menu-toggle {
  min-width: 44px;
  min-height: 44px;
}

Links inside the open panel count as targets too. Give each row enough padding to reach the same height.

Reduced motion

Turn the transitions off for people who ask for less motion.

@media (prefers-reduced-motion: reduce) {
  .bars,
  .bars::before,
  .bars::after,
  .site-menu { transition: none; }
}

MDN describes the prefers-reduced-motion media feature as detecting a device setting that minimizes non-essential motion, available across browsers since January 2020. Place the block after the transition rules, so it wins on source order.

The state change stays visible: the X appears and the panel opens instantly.

Why a CSS hamburger menu stops working

Most breakage traces back to script timing, stacking contexts, transformed ancestors, stale open state or rule order.

SymptomCauseFix
Toggle does nothingScript runs before the button existsAdd defer, or load the script last
Panel appears behind contentHeader sits in a lower stacking contextRaise the z-index of the context holding the header
Fixed panel stays inside the headerAn ancestor has transform or filterRemove it, or move the panel out
Menu stays open after a link tapOn-page links do not reload the pageClose the menu on link click
Toggle shows on wide screensA later display rule overrides the media queryMove the media query below the base rules

Stacking contexts trip people up first. A z-index only competes inside its own stacking context. MDN lists position: fixed, position: sticky, a transform other than none and some will-change values among the properties that start a new one.

So a header with z-index: 1000 still loses when it sits inside a context that ranks below the hero section’s context.

Transformed ancestors cause a quieter version of the same grief. MDN’s containing block documentation says an absolutely or fixed positioned element can take its containing block from the nearest ancestor with a transform or filter.

A header that hides on scroll by animating transform: translateY() becomes that ancestor, and a fixed panel inside it pins to the header box instead of the screen.

Stale open state comes from same-page links. They change the scroll position without a reload, so nothing resets the menu. One listener fixes it:

menu.addEventListener('click', (e) => {
  if (e.target.closest('a')) setOpen(false);
});

Rule order is the last one. Media queries add no specificity, so a base .menu-toggle rule with a display value that comes after the query wins and shows the icon on wide screens.

When should you skip the hamburger menu?

Skip it on desktop widths, and on phones whenever the site has four or five top-level links. That is Nielsen Norman Group’s guidance, built on a study with 179 participants across 6 live sites on desktop and smartphones (Pernice and Budiu, June 26 and July 24, 2016).

The figures from that study point the same way.

  • Navigation use was 27% with hidden navigation versus 48% visible on desktop, and 57% hidden versus 86% combo on mobile.
  • Content discoverability fell by more than 20% with hidden navigation.
  • Tasks took at least 39% longer on desktop and 15% longer on mobile.

The data dates from 2016. NN/G expected only familiarity with the icon to improve over time, and called low salience, low information scent and extra work permanent costs.

NN/G’s two 2016 articles also differ on the phone threshold: the June piece says 4 or fewer top-level links should stay visible, the July follow-up says 4 to 5.

My short version is this. On wide screens, show the links. On phones with up to five links, show them too, after checking they fit at the narrowest width you support. With more links than that, use combo navigation, a few visible links plus the toggle, which beat fully hidden navigation in the figures above.

When the toggle stays, NN/G’s mobile guidelines ask for a larger footprint, a label, a clear separation from the logo and, where possible, sticky navigation so the control stays reachable.

Its mobile comparison shows why. SupermarketHQ’s large Browse button drew significantly more navigation use than Bloomberg’s small unlabeled arrow beside the logo, which many participants missed altogether.

NN/G’s closing advice is a usability test on your own visitors, because guidelines depend on context.

A hamburger menu fits a phone layout with more than five top-level links and nothing else competing for the header.

CSS hamburger menu FAQ

Who designed the hamburger icon?

Norm Cox designed it for the Xerox Star, introduced in 1981, as a container for contextual menu choices. By Cox’s own account, he kept the three bars as simple as a road sign and made them mimic the menu list they opened.

Can I make a hamburger menu with Tailwind CSS?

Yes, using responsive prefixes instead of a hand-written media query. Tailwind’s breakpoints are mobile-first and based on min-width, with md starting at 768px, so md:hidden on the button and hidden md:flex on the list swap the toggle for the inline links.

A short script still has to flip aria-expanded.

Which Bootstrap classes build a hamburger menu?

The .navbar-toggler class goes on the button, .navbar-collapse on the list container, and a .navbar-expand-* class, from sm to xxl, sets the breakpoint.

The toggler keeps a constant “Toggle navigation” label, carries aria-expanded and aria-controls, and needs Bootstrap’s JavaScript to open.

How do I add a submenu inside a hamburger menu?

Keep the parent item as a link, add a sibling disclosure button with aria-expanded and aria-controls, and nest the submenu list inside the same list item. W3C’s APG disclosure navigation example with top-level links uses that structure.

Each parent needs its own tap target, because touch screens have no hover.

Testing a CSS hamburger menu before launch

Run the keyboard-only walkthrough before any audit. Automated checks cover only part of the problem, and the order below puts the manual one first for that reason.

  1. Walk through it by keyboard with Tab, Space, Enter and Escape, and confirm focus returns to the toggle.
  2. Run the Lighthouse accessibility audit in Chrome DevTools.
  3. Do a pass on a physical phone.

Lighthouse states that automatic detection finds only a subset of accessibility issues, so a green score leaves Escape handling and focus return unverified. Chrome’s documentation calls DevTools device mode a first-order approximation that never simulates some mobile behaviors, which makes a physical phone the last gate.

Repeat the sequence whenever the toggle mechanism changes, for example when a checkbox hack gives way to a button, because the two expose state differently (position verified October 12, 2026).

A web accessibility checklist covers the rest of the page once the toggle passes.