Build a hamburger menu with CSS and a little JavaScript

A hamburger menu is a button that shows and hides the site links on narrow screens. CSS draws the icon and animates it; about 20 lines of JavaScript keep it accessible.

A hamburger menu is a <button> with three lines drawn in CSS. Clicking it flips aria-expanded between "false" and "true". The CSS reads that attribute to turn the lines into an X and slide the links into view.

A media query hides the button on wide screens, where the links fit in one row.

Click the icon below, then turn on slow motion to watch each line move.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hamburger icon to X</title>
<style>
  body {
    margin: 0; min-height: 100vh; display: grid; place-content: center; gap: 14px;
    font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; text-align: center;
  }
  .burger {
    position: relative; width: 48px; height: 48px; margin: 0 auto;
    border: 0; border-radius: 10px; background: #1d2330; cursor: pointer;
  }
  .burger:focus-visible { outline: 3px solid #2563eb; outline-offset: 3px; }

  /* the three lines: 24px wide, 4px tall, centres 8px apart */
  .burger span {
    position: absolute; left: 12px; width: 24px; height: 4px;
    border-radius: 2px; background: #fff;
    transform-origin: center;  /* the default, written out: each line turns around its own middle */
    transition: transform .25s ease, opacity .2s ease;
  }
  .burger span:nth-child(1) { top: 14px; }
  .burger span:nth-child(2) { top: 22px; }
  .burger span:nth-child(3) { top: 30px; }

  /* open: move the outer lines to the middle, then turn them */
  .burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
  .burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
  .burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }

  /* people who ask for less motion get an instant swap */
  @media (prefers-reduced-motion: reduce) {
    .burger span { transition: none; }
  }

  .slow .burger span { transition-duration: 1.2s; }
  code { font: 600 13px ui-monospace, Consolas, monospace; background: #e6e9ef; border-radius: 5px; padding: 2px 6px; }
  label { font-size: 13px; color: #5b6270; }
</style>
</head>
<body>
<button class="burger" id="burger" type="button" aria-label="Menu" aria-expanded="false">
  <span></span><span></span><span></span>
</button>
<div>aria-expanded = <code id="state">false</code></div>
<label><input type="checkbox" id="slow"> Slow motion</label>

<script>
  const burger = document.getElementById('burger');

  burger.addEventListener('click', () => {
    const open = burger.getAttribute('aria-expanded') === 'true';
    burger.setAttribute('aria-expanded', String(!open));  // the CSS reads this attribute
    document.getElementById('state').textContent = String(!open);
  });

  // only for this demo: stretch the animation so you can watch it
  document.getElementById('slow').addEventListener('change', (e) => {
    document.body.classList.toggle('slow', e.target.checked);
  });
</script>
</body>
</html>
The icon alone. The whole animation is driven by the aria-expanded attribute.

This page is about the menu button and its panel. Building the bar itself is covered in HTML and CSS navbar, and the <nav> element and its landmark role in the HTML nav tag.

Start with a real button

The markup is short, and every attribute has a job:

<button class="burger" type="button" aria-label="Menu"
        aria-expanded="false" aria-controls="site-menu">
  <span></span><span></span><span></span>
</button>
<nav aria-label="Main">
  <ul class="menu" id="site-menu"> ... links ... </ul>
</nav>
  • <button> gets focus with Tab and fires click on Enter and Space by itself. A <div> with a click listener does neither.
  • aria-label="Menu" gives the icon a name. The three empty spans have no text to read out.
  • aria-expanded says whether the menu is open. It must change every time the menu does.
  • aria-controls points at the id of the list the button opens.

Draw the icon and animate it into an X

Each line is a <span> with position: absolute, a width, a height and a background. Pseudo-elements (::before and ::after on one span) work too. Separate spans are easier to read and to animate one by one.

Move the outer lines to the middle first, then turn them. Written the other way round, the X lands off-centre.
Move the outer lines to the middle first, then turn them. Written the other way round, the X lands off-centre.

The open state is two moves. The top line slides down to the middle and turns 45 degrees. The bottom line slides up and turns -45 degrees. The middle line fades out.

.burger span { transition: transform .25s ease, opacity .2s ease; }
.burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }

The distance (8px here) is the gap between the centres of two neighbouring lines. Get it wrong and the X is lopsided.

Order matters inside transform. translateY(8px) rotate(45deg) moves straight down, then turns. rotate(45deg) translateY(8px) moves along the turned line, so the X lands off-centre. CSS transform explains the order rule.

Some people set their system to reduce motion. Respect it with one rule:

@media (prefers-reduced-motion: reduce) {
  .burger span { transition: none; }
}

The button and the script stay the same. Only the CSS for the closed and open list changes. Switch between the two versions here:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drop-down or drawer</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .pick { display: flex; justify-content: center; gap: 6px; margin-bottom: 12px; font-size: 14px; }
  .pick label { padding: 6px 12px; border: 1px solid #c9cdd4; border-radius: 99px; background: #fff; cursor: pointer; }
  .pick label:has(input:checked) { background: #1d2330; color: #fff; border-color: #1d2330; }
  .pick input { accent-color: #fff; margin: 0 4px 0 0; }

  /* a phone-sized page */
  .screen {
    position: relative; max-width: 340px; height: 310px; margin: 0 auto;
    overflow: hidden; border: 1px solid #d5d9e0; border-radius: 16px; background: #fff;
  }
  .bar {
    position: relative; z-index: 3; height: 52px; padding: 0 8px 0 16px;
    display: flex; align-items: center; justify-content: space-between;
    background: #fff; border-bottom: 1px solid #e5e7eb; font-weight: 700;
  }
  .content { padding: 16px; }
  .content i { display: block; height: 10px; margin: 0 0 10px; border-radius: 5px; background: #eceef2; }

  /* the burger from the first example, a little smaller */
  .burger { position: relative; width: 40px; height: 40px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
  .burger span { position: absolute; left: 9px; width: 22px; height: 3px; border-radius: 2px; background: #1d2330; transition: transform .25s, opacity .2s; }
  .burger span:nth-child(1) { top: 12px; } .burger span:nth-child(2) { top: 18.5px; } .burger span:nth-child(3) { top: 25px; }
  .burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
  .burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
  .burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

  /* closed: moved out of view AND visibility: hidden, so Tab and screen readers skip it */
  .menu {
    position: absolute; z-index: 2; margin: 0; padding: 8px 0; list-style: none; background: #fff;
    visibility: hidden;
    transition: transform .25s ease, visibility 0s .25s;  /* hide only after the slide ends */
  }
  .menu a { display: block; padding: 11px 18px; color: #1d2330; text-decoration: none; }
  .menu a:hover, .menu a:focus-visible { background: #eef2ff; }

  .menu.drop   { top: 52px; left: 0; right: 0; transform: translateY(-100%); box-shadow: 0 10px 20px rgba(0,0,0,.1); }
  .menu.drawer { top: 52px; bottom: 0; right: 0; width: 72%; transform: translateX(100%); box-shadow: -8px 0 20px rgba(0,0,0,.12); }

  .menu.open { visibility: visible; transform: none; transition: transform .25s ease, visibility 0s; }

  .shade { position: absolute; inset: 52px 0 0; z-index: 1; background: rgba(15,23,42,.35); opacity: 0; pointer-events: none; transition: opacity .25s; }
  .shade.on { opacity: 1; pointer-events: auto; }

  @media (prefers-reduced-motion: reduce) {
    .menu, .menu.open, .burger span, .shade { transition: none; }
  }
</style>
</head>
<body>
<div class="pick" role="radiogroup" aria-label="Menu style">
  <label><input type="radio" name="style" value="drop" checked>Drop-down</label>
  <label><input type="radio" name="style" value="drawer">Drawer</label>
</div>

<div class="screen">
  <div class="bar">
    Acme
    <button class="burger" id="burger" type="button" aria-label="Menu" aria-expanded="false" aria-controls="menu">
      <span></span><span></span><span></span>
    </button>
  </div>
  <ul class="menu drop" id="menu">
    <li><a href="#">Home</a></li>
    <li><a href="#">Products</a></li>
    <li><a href="#">Pricing</a></li>
    <li><a href="#">Contact</a></li>
  </ul>
  <div class="shade" id="shade"></div>
  <div class="content"><i></i><i style="width:80%"></i><i></i><i style="width:60%"></i><i></i><i style="width:70%"></i><i></i><i style="width:85%"></i></div>
</div>

<script>
  const burger = document.getElementById('burger');
  const menu = document.getElementById('menu');
  const shade = document.getElementById('shade');

  function setOpen(open) {
    burger.setAttribute('aria-expanded', String(open));
    menu.classList.toggle('open', open);
    shade.classList.toggle('on', open && menu.classList.contains('drawer'));
  }

  burger.addEventListener('click', () => setOpen(burger.getAttribute('aria-expanded') !== 'true'));
  shade.addEventListener('click', () => setOpen(false));
  menu.addEventListener('click', (e) => { if (e.target.closest('a')) { e.preventDefault(); setOpen(false); } });

  // switch between the two versions: only the class on the list changes
  document.querySelectorAll('input[name="style"]').forEach((r) => r.addEventListener('change', () => {
    setOpen(false);
    menu.className = 'menu ' + r.value;
  }));
</script>
</body>
</html>
One button, one list. The radio buttons only swap the class on the list.
A drop-down suits a short list. A drawer gives a long list the full height, and needs a backdrop.
A drop-down suits a short list. A drawer gives a long list the full height, and needs a backdrop.
Drop-down Off-canvas drawer
Closed position translateY(-100%) translateX(100%)
Room for links Short lists Full screen height
Page behind Still visible Covered by a backdrop
Scroll lock Only if it covers the screen Yes

In both, the closed list has visibility: hidden as well as the transform. Moving a list off screen does not stop Tab from reaching its links. visibility: hidden does, and it also hides them from screen readers.

To keep the slide visible on the way out, delay the visibility change until the transition ends:

.menu      { visibility: hidden;  transition: transform .25s, visibility 0s .25s; }
.menu.open { visibility: visible; transition: transform .25s, visibility 0s; transform: none; }

Only on narrow screens

Write the narrow layout first, then undo it at the width where the links fit in one row. The finished example uses 700px:

@media (min-width: 700px) {
  .burger, .backdrop { display: none; }
  .menu { position: static; display: flex; visibility: visible; transform: none; transition: none; }
}

Pick the number by measuring your own labels. A general introduction to breakpoints is in media queries.

The script needs the same number. window.matchMedia('(min-width: 700px)') fires a change event when the screen crosses it, and that is where you close a menu that was left open.

A menu that only closes from its own button feels stuck. Besides the button, four exits are expected, and all five should call the same function.

Five ways to close. One setOpen(false) resets everything, so no exit forgets a step.
Five ways to close. One setOpen(false) resets everything, so no exit forgets a step.
function setOpen(open) {
  burger.setAttribute('aria-expanded', String(open));
  menu.classList.toggle('open', open);
  backdrop.hidden = !open;
  main.inert = open;
  document.documentElement.classList.toggle('menu-open', open);
}
  1. Escape: a keydown listener on document checks e.key === 'Escape', closes, then calls burger.focus().
  2. Link click: a click listener on the list closes when the target is inside an <a>. The link still navigates.
  3. Outside click: a click listener on document closes when the target is not inside the header.
  4. Widening past the breakpoint: the matchMedia change listener closes.

Focus and scroll lock

Focus should never be lost. After Escape it goes back to the button, because the element that had focus just disappeared. When the list comes right after the button in the page, Tab already moves into it, so opening can leave focus where it is.

A panel that covers the page needs two more things:

  • inert on the page behind. Tab then cannot move into content hidden under the backdrop, and clicks there are ignored.
  • A scroll lock. Toggle a class on <html> and give it overflow: hidden, so the page behind stays put while the menu is open.

Here is everything together. The frame in this article is narrower than 700px, so it shows the phone layout.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Responsive header with a hamburger menu</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; color: #1d2330; background: #fff; }
  html.menu-open { overflow: hidden; }  /* scroll lock while the menu is open */

  .site-header {
    position: sticky; top: 0; z-index: 10; height: 56px; padding: 0 12px 0 20px;
    display: flex; align-items: center; justify-content: space-between;
    background: #fff; border-bottom: 1px solid #e5e7eb;
  }
  .logo { font-weight: 800; color: #1d2330; text-decoration: none; }

  .burger { position: relative; width: 44px; height: 44px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
  .burger:focus-visible, .menu a:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
  .burger span { position: absolute; left: 10px; width: 24px; height: 3px; border-radius: 2px; background: #1d2330; transition: transform .25s, opacity .2s; }
  .burger span:nth-child(1) { top: 13.5px; } .burger span:nth-child(2) { top: 20.5px; } .burger span:nth-child(3) { top: 27.5px; }
  .burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
  .burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
  .burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

  /* narrow screens (the default): the list is a panel under the header */
  .menu {
    position: fixed; top: 56px; left: 0; right: 0; margin: 0; padding: 8px 0; list-style: none;
    background: #fff; border-bottom: 1px solid #e5e7eb; box-shadow: 0 12px 24px rgba(0,0,0,.12);
    visibility: hidden; opacity: 0; transform: translateY(-8px);
    transition: opacity .2s, transform .2s, visibility 0s .2s;
  }
  .menu.open { visibility: visible; opacity: 1; transform: none; transition: opacity .2s, transform .2s, visibility 0s; }
  .menu a { display: block; padding: 12px 20px; color: #1d2330; text-decoration: none; font-size: 17px; }
  .menu a:hover { background: #f1f5f9; }

  .backdrop { position: fixed; inset: 56px 0 0; z-index: 5; background: rgba(15,23,42,.4); }

  /* 700px and wider: no button, the links sit in the bar */
  @media (min-width: 700px) {
    .burger, .backdrop { display: none; }
    .menu {
      position: static; display: flex; gap: 4px; padding: 0;
      visibility: visible; opacity: 1; transform: none; box-shadow: none; border: 0; transition: none;
    }
    .menu a { padding: 8px 12px; border-radius: 8px; font-size: 15px; }
  }

  @media (prefers-reduced-motion: reduce) {
    .menu, .menu.open, .burger span { transition: none; }
  }

  main { max-width: 640px; margin: 0 auto; padding: 8px 20px 40px; line-height: 1.6; }
  h2 { margin: 26px 0 6px; font-size: 19px; scroll-margin-top: 64px; }  /* clear the sticky header */
</style>
</head>
<body>
<header class="site-header">
  <a class="logo" href="#top">Northwind</a>
  <button class="burger" id="burger" type="button" aria-label="Menu" aria-expanded="false" aria-controls="site-menu">
    <span></span><span></span><span></span>
  </button>
  <nav aria-label="Main">
    <ul class="menu" id="site-menu">
      <li><a href="#features">Features</a></li>
      <li><a href="#pricing">Pricing</a></li>
      <li><a href="#faq">FAQ</a></li>
      <li><a href="#contact">Contact</a></li>
    </ul>
  </nav>
</header>
<div class="backdrop" id="backdrop" hidden></div>

<main id="main">
  <p id="top">Open the menu, then try Tab, Escape, a link, or a click on the dark area. The page behind does not scroll while it is open.</p>
  <h2 id="features">Features</h2>
  <p>Plans, invoices and reports in one place. Everything updates as soon as someone on the team changes it.</p>
  <p>Import from a spreadsheet in one step, then keep editing in the browser.</p>
  <h2 id="pricing">Pricing</h2>
  <p>One plan for small teams, one for larger ones. Both include every feature.</p>
  <p>Pay monthly or yearly. You can switch at any time.</p>
  <h2 id="faq">FAQ</h2>
  <p>Can I export my data? Yes, to CSV at any time.</p>
  <p>Is there a free trial? Yes, fourteen days.</p>
  <h2 id="contact">Contact</h2>
  <p>Write to hello@example.com and we answer within one working day.</p>
</main>

<script>
  const burger = document.getElementById('burger');
  const menu = document.getElementById('site-menu');
  const backdrop = document.getElementById('backdrop');
  const main = document.getElementById('main');
  const wide = window.matchMedia('(min-width: 700px)');  // same value as the CSS

  function isOpen() { return burger.getAttribute('aria-expanded') === 'true'; }

  function setOpen(open) {
    burger.setAttribute('aria-expanded', String(open));
    menu.classList.toggle('open', open);
    backdrop.hidden = !open;
    main.inert = open;                                            // Tab cannot wander behind the menu
    document.documentElement.classList.toggle('menu-open', open); // scroll lock
  }

  burger.addEventListener('click', () => setOpen(!isOpen()));

  // Escape closes and puts focus back on the button
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && isOpen()) { setOpen(false); burger.focus(); }
  });

  // a link was chosen: close, and let the link do its job
  menu.addEventListener('click', (e) => { if (e.target.closest('a') && isOpen()) setOpen(false); });

  // a click anywhere outside the header closes it (the backdrop counts as outside)
  document.addEventListener('click', (e) => {
    if (isOpen() && !e.target.closest('.site-header')) setOpen(false);
  });

  // widened to desktop while open: reset, or the lock and backdrop stay behind
  wide.addEventListener('change', (e) => { if (e.matches) setOpen(false); });
</script>
</body>
</html>
The finished header: Tab, Escape, a link, a click on the dark area, and no scrolling behind the open menu.

The CSS-only checkbox hack

A menu can open without JavaScript. A hidden checkbox holds the state and a <label> acts as the button:

<input type="checkbox" id="nav-toggle" class="visually-hidden">
<label for="nav-toggle">Menu</label>
<ul class="menu"> ... </ul>

<style>
  .menu { display: none; }
  #nav-toggle:checked ~ .menu { display: block; }
</style>

It works for a mouse, but it costs accessibility.

Screen readers announce a checkbox, checked or not, instead of a menu button that is expanded or collapsed. If the checkbox gets display: none, keyboard users cannot reach it at all. Escape, outside clicks and focus return need script anyway.

Use it for a quick prototype. For a live site, the button version above is barely longer.

When it does not work

What you see Cause Fix
Tab skips the menu button, Enter does nothing The button is a <div> or <span> Use <button type="button">
Links are hidden but Tab still lands on them The closed list is only moved off screen Add visibility: hidden when closed
Menu stays open after widening to desktop Nothing resets state at the breakpoint Close in a matchMedia change listener
The page scrolls behind the open drawer No scroll lock Toggle a class with overflow: hidden on <html>
The X is lopsided or the lines swing from a corner transform-origin is not centre, or rotate is written before translate Keep transform-origin: center and write translateY(...) rotate(...)
Screen readers say "collapsed" while the menu is open aria-expanded not updated Set it inside the one open/close function
The icon changes but the menu does not The CSS reads a class, the script sets the attribute (or the reverse) Drive both from the same attribute or class

A menu is easier to judge by using it than by looking at it. A screenshot shows one state, and an .html attachment may open as plain code on a phone, which is exactly where the hamburger appears.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can open and close the menu on their own phones. If you change the code later, the same link shows the new version.

Questions people ask

Can I make a hamburger menu with only CSS?

Yes, with a hidden checkbox and a label, but it is announced as a checkbox instead of a menu button, and it cannot close on Escape, on an outside click or return focus. A button plus a few lines of JavaScript does all of that.

Should the hamburger be a button or a div?

A button. It is reachable with Tab and fires its click with Enter and Space without extra code. A div needs tabindex, a role and key handlers to reach the same point.

What does aria-expanded do on a hamburger button?

It tells screen readers whether the menu it controls is open. Set it to "false" in the HTML and flip it to "true" and back in the click handler. The CSS in this article also reads it to draw the X.

At what width should the hamburger menu appear?

Where your links stop fitting in one row. Measure it with your real labels. This article uses 700px, and the same number has to appear in the CSS media query and in the JavaScript matchMedia call.

How do I stop the page scrolling behind an open menu?

Add a class to the html element while the menu is open, and give that class overflow: hidden. Remove the class in the same function that closes the menu.

Keep reading