Pure CSSMIT licensed

Pure CSS Hamburger Menu Without JavaScript

The checkbox hack, rebuilt for the :has() era: the input still holds the state, but :has(:checked) reads it from the ROOT, so the burger, the header, the fullscreen panel and even the page content can all react — no sibling-order gymnastics, no restructured markup. Compare demo 05 (the ~ combinator version) side by side: same zero-JS guarantee, but here the menu tints the header, scales the page and swaps the burger, all from one selector.

Live Demo
Plain CSSTailwind v4
Try it

Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS

ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link

10% off at checkout with code
Check ZTM Course

The code

<section class="chm-09" aria-label="Pure CSS :has() hamburger menu demo">
  <div class="chm-09__stage">
    <input type="checkbox" id="chm09-t" class="chm-09__state">
    <div class="chm-09__page" aria-hidden="true">
      <div class="chm-09__hero"><b>:has()</b><span>state, readable anywhere</span></div>
      <div class="chm-09__ln" style="--w:82%"></div>
      <div class="chm-09__ln" style="--w:60%"></div>
      <div class="chm-09__ln" style="--w:73%"></div>
    </div>
    <nav class="chm-09__panel" aria-label="Site">
      <a href="#index" style="--i:0">Index</a>
      <a href="#essays" style="--i:1">Essays</a>
      <a href="#library" style="--i:2">Library</a>
      <a href="#colophon" style="--i:3">Colophon</a>
    </nav>
    <header class="chm-09__bar">
      <span class="chm-09__brand">marginalia</span>
      <label for="chm09-t" class="chm-09__burger"><span aria-hidden="true"><i></i><i></i><i></i></span></label>
    </header>
  </div>
</section>
.chm-09,
.chm-09 *,
.chm-09 *::before,
.chm-09 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.chm-09 {
  background: var(--bg);
  width: 100%;
  min-height: 100vh;
  box-sizing: border-box;
  --bg: oklch(0.965 0.008 85);
  --ink: oklch(0.24 0.02 60);
  --mut: oklch(0.52 0.02 60);
  --acc: oklch(0.58 0.16 45);
  --panel: oklch(0.24 0.03 55);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
  display: grid;
  justify-content: center;
}

.chm-09__stage {
  position: relative;
  width: min(400px,88cqi,88vw);
  height: 540px;
  border-radius: 24px;
  overflow: hidden;
  background: var(--bg);
  border: 1px solid oklch(0.87 0.015 80);
}

.chm-09__state {
  position: absolute;
  width: 1px;
  height: 1px;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

.chm-09__bar {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 70px;
  padding: 0 16px 0 24px;
}

.chm-09__brand {
  font-size: 16px;
  font-weight: 700;
  font-style: italic;
  letter-spacing: -.01em;
  transition: color .35s;
}

.chm-09:has(#chm09-t:checked) .chm-09__brand {
  color: oklch(0.95 0.015 80);
}

.chm-09__burger {
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--ink);
  color: oklch(0.96 0.01 80);
  cursor: pointer;
  transition: background .3s,scale .2s;
}

.chm-09__burger:hover {
  scale: 1.06;
}

.chm-09__state:focus-visible ~ .chm-09__bar .chm-09__burger {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger {
  background: oklch(0.96 0.01 80);
  color: var(--panel);
}

.chm-09__burger span {
  position: relative;
  width: 18px;
  height: 12px;
  display: block;
}

.chm-09__burger i {
  position: absolute;
  left: 0;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: translate .3s,rotate .3s .05s,opacity .2s;
}

.chm-09__burger i:nth-child(1) {
  top: 0;
}

.chm-09__burger i:nth-child(2) {
  top: 5px;
}

.chm-09__burger i:nth-child(3) {
  top: 10px;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(1) {
  translate: 0 5px;
  rotate: 45deg;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(2) {
  opacity: 0;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(3) {
  translate: 0 -5px;
  rotate: -45deg;
}

.chm-09__page {
  position: absolute;
  inset: 0;
  padding: 86px 24px 24px;
  display: grid;
  gap: 14px;
  align-content: start;
  transition: scale .5s cubic-bezier(.2,.7,.2,1),filter .5s,opacity .5s;
}

.chm-09:has(#chm09-t:checked) .chm-09__page {
  scale: .96;
  filter: blur(2px);
  opacity: .55;
}

.chm-09__hero {
  height: 190px;
  border-radius: 16px;
  background: linear-gradient(150deg,oklch(0.9 0.035 70),oklch(0.84 0.06 45));
  display: flex;
  flex-direction: column;
  justify-content: end;
  padding: 18px;
  margin-bottom: 6px;
}

.chm-09__hero b {
  font-size: 30px;
  letter-spacing: -.02em;
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.chm-09__hero span {
  font-size: 12.5px;
  color: var(--mut);
}

.chm-09__ln {
  height: 11px;
  width: var(--w);
  border-radius: 99px;
  background: oklch(0.89 0.012 80);
}

.chm-09__panel {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: linear-gradient(170deg,color-mix(in oklch,var(--panel) 92%,white),var(--panel) 60%);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 0 32px;
  opacity: 0;
  scale: 1.04;
  visibility: hidden;
  transition: opacity .4s,scale .5s cubic-bezier(.2,.7,.2,1),visibility 0s .5s;
}

.chm-09:has(#chm09-t:checked) .chm-09__panel {
  opacity: 1;
  scale: 1;
  visibility: visible;
  transition: opacity .4s,scale .5s cubic-bezier(.2,.7,.2,1),visibility 0s 0s;
}

.chm-09__panel a {
  padding: 16px 2px;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -.02em;
  color: oklch(0.95 0.015 80);
  text-decoration: none;
  border-bottom: 1px solid oklch(1 0 0/.12);
  display: flex;
  align-items: center;
  justify-content: space-between;
  opacity: 0;
  translate: 0 14px;
  transition: color .2s,opacity .3s,translate .35s cubic-bezier(.2,.7,.2,1);
}

.chm-09__panel a::after {
  content: "→";
  font-size: 18px;
  color: var(--acc);
  opacity: 0;
  translate: -8px 0;
  transition: opacity .2s,translate .25s;
}

.chm-09__panel a:hover::after,
.chm-09__panel a:focus-visible::after {
  opacity: 1;
  translate: 0 0;
}

.chm-09__panel a:hover,
.chm-09__panel a:focus-visible {
  color: oklch(0.85 0.09 60);
}

.chm-09__panel a:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 4px;
  border-radius: 4px;
}

.chm-09:has(#chm09-t:checked) .chm-09__panel a {
  opacity: 1;
  translate: 0 0;
  transition-delay: 0s,calc(.14s + var(--i)*.06s),calc(.14s + var(--i)*.06s);
}

@media (prefers-reduced-motion: reduce) {
  .chm-09 * {
    transition-duration: .01s !important;
    transition-delay: 0s !important;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Hamburger Menu from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Pure CSS Hamburger Menu Without JavaScript
Source: https://codefronts.com/navigation/css-hamburger-menus/pure-css-hamburger-menu-no-javascript/

The checkbox hack, rebuilt for the :has() era: the input still holds the state, but :has(:checked) reads it from the ROOT, so the burger, the header, the fullscreen panel and even the page content can all react — no sibling-order gymnastics, no restructured markup. Compare demo 05 (the ~ combinator version) side by side: same zero-JS guarantee, but here the menu tints the header, scales the page and swaps the burger, all from one selector.
## HTML
```html
<section class="chm-09" aria-label="Pure CSS :has() hamburger menu demo">
  <div class="chm-09__stage">
    <input type="checkbox" id="chm09-t" class="chm-09__state">
    <div class="chm-09__page" aria-hidden="true">
      <div class="chm-09__hero"><b>:has()</b><span>state, readable anywhere</span></div>
      <div class="chm-09__ln" style="--w:82%"></div>
      <div class="chm-09__ln" style="--w:60%"></div>
      <div class="chm-09__ln" style="--w:73%"></div>
    </div>
    <nav class="chm-09__panel" aria-label="Site">
      <a href="#index" style="--i:0">Index</a>
      <a href="#essays" style="--i:1">Essays</a>
      <a href="#library" style="--i:2">Library</a>
      <a href="#colophon" style="--i:3">Colophon</a>
    </nav>
    <header class="chm-09__bar">
      <span class="chm-09__brand">marginalia</span>
      <label for="chm09-t" class="chm-09__burger"><span aria-hidden="true"><i></i><i></i><i></i></span></label>
    </header>
  </div>
</section>
```
## CSS
```css
.chm-09,
.chm-09 *,
.chm-09 *::before,
.chm-09 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.chm-09 {
  background: var(--bg);
  width: 100%;
  min-height: 100vh;
  box-sizing: border-box;
  --bg: oklch(0.965 0.008 85);
  --ink: oklch(0.24 0.02 60);
  --mut: oklch(0.52 0.02 60);
  --acc: oklch(0.58 0.16 45);
  --panel: oklch(0.24 0.03 55);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
  display: grid;
  justify-content: center;
}

.chm-09__stage {
  position: relative;
  width: min(400px,88cqi,88vw);
  height: 540px;
  border-radius: 24px;
  overflow: hidden;
  background: var(--bg);
  border: 1px solid oklch(0.87 0.015 80);
}

.chm-09__state {
  position: absolute;
  width: 1px;
  height: 1px;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

.chm-09__bar {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 70px;
  padding: 0 16px 0 24px;
}

.chm-09__brand {
  font-size: 16px;
  font-weight: 700;
  font-style: italic;
  letter-spacing: -.01em;
  transition: color .35s;
}

.chm-09:has(#chm09-t:checked) .chm-09__brand {
  color: oklch(0.95 0.015 80);
}

.chm-09__burger {
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--ink);
  color: oklch(0.96 0.01 80);
  cursor: pointer;
  transition: background .3s,scale .2s;
}

.chm-09__burger:hover {
  scale: 1.06;
}

.chm-09__state:focus-visible ~ .chm-09__bar .chm-09__burger {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger {
  background: oklch(0.96 0.01 80);
  color: var(--panel);
}

.chm-09__burger span {
  position: relative;
  width: 18px;
  height: 12px;
  display: block;
}

.chm-09__burger i {
  position: absolute;
  left: 0;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: translate .3s,rotate .3s .05s,opacity .2s;
}

.chm-09__burger i:nth-child(1) {
  top: 0;
}

.chm-09__burger i:nth-child(2) {
  top: 5px;
}

.chm-09__burger i:nth-child(3) {
  top: 10px;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(1) {
  translate: 0 5px;
  rotate: 45deg;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(2) {
  opacity: 0;
}

.chm-09:has(#chm09-t:checked) .chm-09__burger i:nth-child(3) {
  translate: 0 -5px;
  rotate: -45deg;
}

.chm-09__page {
  position: absolute;
  inset: 0;
  padding: 86px 24px 24px;
  display: grid;
  gap: 14px;
  align-content: start;
  transition: scale .5s cubic-bezier(.2,.7,.2,1),filter .5s,opacity .5s;
}

.chm-09:has(#chm09-t:checked) .chm-09__page {
  scale: .96;
  filter: blur(2px);
  opacity: .55;
}

.chm-09__hero {
  height: 190px;
  border-radius: 16px;
  background: linear-gradient(150deg,oklch(0.9 0.035 70),oklch(0.84 0.06 45));
  display: flex;
  flex-direction: column;
  justify-content: end;
  padding: 18px;
  margin-bottom: 6px;
}

.chm-09__hero b {
  font-size: 30px;
  letter-spacing: -.02em;
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.chm-09__hero span {
  font-size: 12.5px;
  color: var(--mut);
}

.chm-09__ln {
  height: 11px;
  width: var(--w);
  border-radius: 99px;
  background: oklch(0.89 0.012 80);
}

.chm-09__panel {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: linear-gradient(170deg,color-mix(in oklch,var(--panel) 92%,white),var(--panel) 60%);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 0 32px;
  opacity: 0;
  scale: 1.04;
  visibility: hidden;
  transition: opacity .4s,scale .5s cubic-bezier(.2,.7,.2,1),visibility 0s .5s;
}

.chm-09:has(#chm09-t:checked) .chm-09__panel {
  opacity: 1;
  scale: 1;
  visibility: visible;
  transition: opacity .4s,scale .5s cubic-bezier(.2,.7,.2,1),visibility 0s 0s;
}

.chm-09__panel a {
  padding: 16px 2px;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -.02em;
  color: oklch(0.95 0.015 80);
  text-decoration: none;
  border-bottom: 1px solid oklch(1 0 0/.12);
  display: flex;
  align-items: center;
  justify-content: space-between;
  opacity: 0;
  translate: 0 14px;
  transition: color .2s,opacity .3s,translate .35s cubic-bezier(.2,.7,.2,1);
}

.chm-09__panel a::after {
  content: "→";
  font-size: 18px;
  color: var(--acc);
  opacity: 0;
  translate: -8px 0;
  transition: opacity .2s,translate .25s;
}

.chm-09__panel a:hover::after,
.chm-09__panel a:focus-visible::after {
  opacity: 1;
  translate: 0 0;
}

.chm-09__panel a:hover,
.chm-09__panel a:focus-visible {
  color: oklch(0.85 0.09 60);
}

.chm-09__panel a:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 4px;
  border-radius: 4px;
}

.chm-09:has(#chm09-t:checked) .chm-09__panel a {
  opacity: 1;
  translate: 0 0;
  transition-delay: 0s,calc(.14s + var(--i)*.06s),calc(.14s + var(--i)*.06s);
}

@media (prefers-reduced-motion: reduce) {
  .chm-09 * {
    transition-duration: .01s !important;
    transition-delay: 0s !important;
  }
}
```

How this works

The old hack could only style LATER siblings of the input. :has() turns the state into a root-level condition any descendant can key off:

/* 2015: state reaches siblings only */
#t:checked ~ .panel{ … }

/* 2026: state reaches EVERYTHING */
.chm-09:has(#chm09-t:checked) .panel { opacity:1; scale:1; }
.chm-09:has(#chm09-t:checked) .bar   { background:transparent; }
.chm-09:has(#chm09-t:checked) .page  { scale:.96; filter:blur(2px); }

That third line is the tell — the page content sits in a completely different subtree from the input, and it still responds. This is what previously forced people into JavaScript.

The panel here is a fullscreen fade-and-settle (opacity + scale:1.04→1 + delayed visibility) rather than a slide, to demonstrate a second point: with the state readable anywhere, your open animation is unconstrained by where the panel lives in the DOM. Links stagger via transition-delay: calc(var(--i)*.06s), and the label's focus ring is re-projected from the hidden input exactly as in demo 05 — that discipline doesn't change. Same honest limits as every no-JS menu: announces as a checkbox, no Escape, no focus management. What you gain over demo 05 is architectural freedom; what you still don't get is ARIA truth. Choose accordingly.

Make it yours

  • Page-behind treatment is one rule — scale:.96; filter:blur(2px). Delete it for a plain overlay, or push to grayscale(1) for editorial drama.
  • Panel motion: swap scale:1.04 for translate:0 20px for a rise-in; the :has() wiring is animation-agnostic.
  • Because state is root-readable, you can add an 'open' progress bar in the header, tint the brand, swap logo color — anything, one .chm-09:has(:checked) prefix away.
  • Multiple independent menus on one page: keep each input id unique and scope each :has() to its own component root, exactly as done here.

Gotchas — read before shipping

  • :has() invalidation is fast but not free — keep the :has() conditions on a component root, not on html or body, so the browser re-checks a small subtree per toggle.
  • This pattern still can't close on Escape or return focus — those are JS behaviors. If your audit requires them (most app-nav audits do), graduate to demo 02/17.
  • Firefox shipped :has() in 121 (Dec 2023) — for long-tail enterprise Firefox ESR installs, demo 05's sibling version is the safe fallback; ship both behind @supports(selector(:has(*))) if you must.
  • Keep the input focusable (clip pattern, never display:none) — Space is the ONLY keyboard affordance a no-JS menu has.

Browser support

ChromeSafariFirefoxEdge
111+16.4+121+111+

:has() — Chrome 105, Safari 15.4, Firefox 121. Everything else here is decade-old CSS; wrap the enhancements in @supports(selector(:has(*))) and fall back to demo 05's engine for older Firefox.

Search CodeFronts

Loading…