Hamburger Menu Without JavaScript
A responsive mobile menu in two versions: the classic checkbox technique — done accessibly — and the newer popover attribute, which adds Esc and tap-outside-to-close for free. Both with zero JavaScript.
- JavaScript
- 0 KB
- Built with
:checked, container queries,popover- Browsers
- All current browsers (Chrome, Edge, Firefox, Safari)
- Updated
No. A hidden checkbox can hold the open/closed state: its <label> is the menu button, and :checked slides the menu in. Even better in current browsers: a <button popovertarget> opens a popover menu that closes with Esc or a tap outside — all built in.
Live demo: the same HTML, two widths
Both frames contain identical HTML. The menu reacts to the width of its container, not the browser window — so you can see the phone version and the desktop version side by side. Tap Menu in the phone frame, or tab to it and press Space.
Phone width
Desktop width — same HTML, same CSS
How the CSS hamburger menu works
- A checkbox holds the state
Checked means open. The
<label for="…">is the visible “Menu” button — clicking a label toggles its checkbox, no script needed. - Hidden visually, not from the keyboard
Most tutorials use
display: noneon the checkbox, which removes it from the tab order — keyboard users can't open the menu at all. Here it is only visually hidden, so Tab reaches it and Space toggles it. :checked ~opens the drawerThe checkbox comes before the button and the nav in the HTML. So
.ui-burger__toggle:checked ~ .ui-burger__navcan reveal the drawer, and:checked +turns the icon into an ✕.- Closed means unreachable
The closed drawer has
visibility: hidden, so its links can't be tabbed to while invisible — a common bug with menus that are only moved off-screen. - No sideways scrolling
The drawer is revealed with
clip-pathinstead of being parked off-screen withright: -100%, which often makes the whole page scroll sideways on phones. - One menu for all screens
A container query switches between the row of links and the drawer at 40rem. There is one set of links in the HTML — no separate mobile menu to maintain, and no duplicate links for crawlers.
Copy the HTML and CSS
Exactly the code running in the demo. Keep the order of the elements — the checkbox must come before the label and the nav for the ~ and + selectors to work. All class names start with ui-.
<header class="ui-burger">
<a class="ui-burger__logo" href="/lab/css-first-ui/">CSS-First UI</a>
<input class="ui-burger__toggle" type="checkbox" id="ui-burger-toggle">
<label class="ui-burger__button" for="ui-burger-toggle">
<span class="ui-burger__icon" aria-hidden="true"></span>
<span>Menu</span>
</label>
<nav class="ui-burger__nav" aria-label="Main">
<ul>
<li><a href="/lab/css-first-ui/dropdown-menu-without-javascript/">Dropdown</a></li>
<li><a href="/lab/css-first-ui/css-mega-menu/">Mega menu</a></li>
<li><a href="/lab/css-first-ui/hamburger-menu-without-javascript/" aria-current="page">Hamburger</a></li>
<li><a href="/lab/css-first-ui/breadcrumb-navigation-schema/">Breadcrumbs</a></li>
<li><a href="/tools/">Free tools</a></li>
<li><a href="/services/seo/china/">Baidu SEO</a></li>
</ul>
</nav>
</header>/* Hamburger menu — no JavaScript (checkbox version).
Wide container: links in a row. Narrow container (under 40rem):
a "Menu" button that opens a drawer from the right. */
.ui-burger {
--ui-bg: #ffffff;
--ui-text: #1b1f3b;
--ui-accent: #4f46e5;
--ui-hover: #eef0ff;
container-type: inline-size;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: var(--ui-bg);
box-shadow: 0 1px 0 rgb(15 18 40 / 0.08);
font: 500 0.9375rem/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.ui-burger ul {
margin: 0;
padding: 0;
list-style: none;
}
.ui-burger a {
color: var(--ui-text);
text-decoration: none;
}
.ui-burger a:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
border-radius: 4px;
}
.ui-burger__logo {
font-weight: 800;
letter-spacing: -0.01em;
}
/* Wide: a simple row of links */
.ui-burger__nav ul {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem;
}
.ui-burger__nav a {
display: block;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.ui-burger__nav a:hover {
background: var(--ui-hover);
}
.ui-burger__nav a[aria-current="page"] {
color: var(--ui-accent);
font-weight: 700;
}
/* The checkbox holds the open/closed state. It is visually hidden,
NOT display:none, so it stays reachable with the keyboard. */
.ui-burger__toggle {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ui-burger__button {
display: none;
}
/* Narrow: button + drawer */
@container (max-width: 40rem) {
.ui-burger__button {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
color: var(--ui-text);
}
.ui-burger__button:hover {
background: var(--ui-hover);
}
.ui-burger__toggle:focus-visible + .ui-burger__button {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
/* Three lines from one element */
.ui-burger__icon,
.ui-burger__icon::before,
.ui-burger__icon::after {
display: block;
width: 1.25rem;
height: 2px;
border-radius: 2px;
background: currentColor;
transition: transform 0.25s, background-color 0.25s;
}
.ui-burger__icon {
position: relative;
}
.ui-burger__icon::before,
.ui-burger__icon::after {
content: "";
position: absolute;
left: 0;
}
.ui-burger__icon::before { top: -6px; }
.ui-burger__icon::after { top: 6px; }
/* The drawer: below the header, full remaining height.
Closed = clipped away AND invisible, so its links can't be
tabbed to while it is closed. clip-path causes no sideways scroll. */
.ui-burger__nav {
position: absolute;
top: 100%;
right: 0;
z-index: 20;
width: min(18rem, 85%);
height: calc(100dvh - 100%);
overflow-y: auto;
padding: 1rem;
background: var(--ui-bg);
box-shadow: -12px 12px 32px rgb(15 18 40 / 0.18);
clip-path: inset(0 0 0 100%);
visibility: hidden;
transition: clip-path 0.3s ease, visibility 0s 0.3s;
}
.ui-burger__nav ul {
flex-direction: column;
justify-content: flex-start;
}
.ui-burger__nav a {
padding: 0.8rem 0.75rem;
}
/* Open state */
.ui-burger__toggle:checked ~ .ui-burger__nav {
clip-path: inset(0 -3rem -3rem 0); /* room for the shadow */
visibility: visible;
transition-delay: 0s;
}
.ui-burger__toggle:checked + .ui-burger__button .ui-burger__icon {
background-color: transparent;
}
.ui-burger__toggle:checked + .ui-burger__button .ui-burger__icon::before {
transform: translateY(6px) rotate(45deg);
}
.ui-burger__toggle:checked + .ui-burger__button .ui-burger__icon::after {
transform: translateY(-6px) rotate(-45deg);
}
}
/* Optional, for a real site header: stop the page scrolling behind the open menu.
body:has(.ui-burger__toggle:checked) { overflow: hidden; } */On a real site, make the header position: sticky; top: 0 — the drawer then always opens right below it.
The modern alternative: a popover menu
Since 2024 all major browsers support the popover attribute. A normal <button> with popovertarget opens the menu, and the browser adds what the checkbox version can't do: Esc closes it, a tap outside closes it, and screen readers hear a real button with an open/closed state. The drawer opens over the whole window, like on a phone.
<div class="ui-pop">
<button class="ui-pop__open" type="button" popovertarget="ui-pop-menu">
<span class="ui-pop__icon" aria-hidden="true"></span>
Menu
</button>
<nav class="ui-pop__nav" id="ui-pop-menu" popover aria-label="Main">
<button class="ui-pop__close" type="button" popovertarget="ui-pop-menu" popovertargetaction="hide">Close</button>
<ul>
<li><a href="/lab/css-first-ui/accordion-without-javascript/">Accordion</a></li>
<li><a href="/lab/css-first-ui/tabs-without-javascript/">Tabs</a></li>
<li><a href="/lab/css-first-ui/modal-without-javascript/">Modal</a></li>
<li><a href="/tools/">Free tools</a></li>
<li><a href="/#promise">Contact</a></li>
</ul>
</nav>
</div>/* Hamburger menu — no JavaScript (popover version).
The browser adds Esc, click-outside-to-close and the
open/closed state for screen readers. */
.ui-pop {
--ui-bg: #ffffff;
--ui-text: #1b1f3b;
--ui-accent: #4f46e5;
--ui-hover: #eef0ff;
font: 500 0.9375rem/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.ui-pop button {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.9rem;
border: 0;
border-radius: 8px;
background: var(--ui-bg);
color: var(--ui-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.ui-pop button:hover {
background: var(--ui-hover);
}
.ui-pop :focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.ui-pop__icon,
.ui-pop__icon::before,
.ui-pop__icon::after {
display: block;
width: 1.25rem;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.ui-pop__icon {
position: relative;
}
.ui-pop__icon::before,
.ui-pop__icon::after {
content: "";
position: absolute;
left: 0;
}
.ui-pop__icon::before { top: -6px; }
.ui-pop__icon::after { top: 6px; }
/* The drawer: a popover in the browser's top layer */
.ui-pop__nav {
position: fixed;
inset: 0 0 0 auto;
width: min(20rem, 85vw);
height: 100dvh;
max-height: none;
margin: 0;
padding: 1rem;
border: 0;
background: var(--ui-bg);
color: var(--ui-text);
box-shadow: -12px 0 32px rgb(15 18 40 / 0.25);
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease, overlay 0.3s allow-discrete, display 0.3s allow-discrete;
}
.ui-pop__nav:popover-open {
transform: none;
}
@starting-style {
.ui-pop__nav:popover-open {
transform: translateX(100%);
}
}
.ui-pop__nav::backdrop {
background: rgb(10 12 25 / 0.45);
}
.ui-pop__nav ul {
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.ui-pop__nav a {
display: block;
padding: 0.8rem 0.75rem;
border-radius: 6px;
color: var(--ui-text);
text-decoration: none;
}
.ui-pop__nav a:hover {
background: var(--ui-hover);
}
.ui-pop__close {
margin-left: auto;
}This version is a drawer on every screen size. To show a row of links on desktop instead, combine it with the container query from the checkbox version — or keep the checkbox version for the widest browser support.
Checkbox or popover? Accessibility compared
| Feature | Checkbox | Popover | Details |
|---|---|---|---|
| Keyboard | Works | Works | Checkbox: Tab to it, Space toggles. Popover: a real button, Enter or Space. |
| Screen readers | OK | Works | The checkbox is announced as “Menu, checkbox, not checked” — understandable, but not a button. The popover button reports expanded/collapsed. |
| Esc to close | Needs JS | Built in | Popovers close with Esc natively. |
| Tap outside to close | Needs JS | Built in | Called “light dismiss” — part of popover="auto". |
| Desktop row of links | Works | Extra CSS | The checkbox version switches layouts with a container query. |
| Browser support | All | 2024+ | Popover: Chrome/Edge 114+, Safari 17+, Firefox 125+. The slide animation needs slightly newer versions; older ones just show the menu. |
- Moving focus into the menu when it opens, and back to the button when it closes (the popover version already returns focus to the button).
- Closing the menu after an in-page link like
#contact— normal page links load a new page anyway. - Analytics on menu opens. Add any of these on top — the menu keeps working if the script fails.
Mobile menus and SEO
Google indexes the mobile version of your pages, and in China most searches happen on phones. So the mobile menu is the navigation search engines judge. If it only works after a script runs, a crawler that doesn't run the script — or a visitor on a slow connection whose script fails to load — gets a page without working navigation.
Both versions on this page keep all links in the HTML from the first byte. Hiding them with CSS while the menu is closed is normal navigation behaviour, and links in a closed menu are still followed.
Avoid building two menus — one for desktop, one for mobile. That doubles every link in the HTML, makes the page heavier and means two lists to keep in sync. One list of links with responsive CSS is cleaner for users, for crawlers and for whoever maintains the site.
Questions about CSS hamburger menus
Can you make a hamburger menu with only HTML and CSS?
Yes — with a checkbox and a label (works in every browser) or with the popover attribute (all browsers since 2024). Both demos on this page use no JavaScript at all.
Is the checkbox hack accessible?
It can be, if you don't hide the checkbox with display: none. Keep it visually hidden but focusable, show a focus ring on the label, and hide the closed menu with visibility. Screen readers then announce a “Menu” checkbox — usable, though a real button is better. That is what the popover version gives you.
How do I close the menu by tapping outside it?
With the popover version it works automatically. The checkbox version can't do it without a script — or with a full-screen second label behind the drawer, which adds a click target but isn't announced well by screen readers.
Do search engines follow links in a closed mobile menu?
Yes, if the links are in the HTML. Hiding them with CSS is normal for navigation. The risk is menus whose links are only added by JavaScript.
Should I use a hamburger menu on desktop too?
Usually not. On wide screens visible links are found faster. That is why the checkbox demo shows a row of links when there is room, and a drawer only when space is tight.
This FAQ is itself built without JavaScript — see the accordion pattern.
Does your mobile site work for Baidu and for Baidu Ads?
We check Chinese mobile sites for navigation, speed and content that crawlers can't see, and build landing pages that convert. Then we run Baidu SEO and Baidu Ads campaigns for Western companies.
CSS-First UI Patterns are written and maintained by Jademond Digital, a Chinese digital marketing agency founded in Guangzhou in 2012. We build and audit websites for Baidu, Google and AI search — which is why we care what works without JavaScript. About us →