Build a Responsive Navbar With Pure CSS (No JavaScript)
Build a fully responsive hamburger navbar using only HTML and CSS — covering both the checkbox hack and the modern CSS :has() approach with accessibility notes.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
There's something satisfying about solving a problem with fewer tools than you'd expect. A responsive hamburger navbar with no JavaScript feels like that — it works, it's lighter, and it's one fewer thing that can break.
I've built these for client projects, personal sites, and quick prototypes. Let me walk you through both the classic approach and the modern CSS-only method, with honest notes on when each is appropriate.
What We're Building
A navigation bar that:
- Shows a horizontal nav on desktop
- Collapses to a hamburger icon on mobile
- Opens/closes the menu when the hamburger is clicked
- Looks decent without a framework
- Works with zero JavaScript
Approach 1: The Checkbox Hack
The checkbox hack has been around for years and still works reliably. The idea: a hidden <input type="checkbox"> tracks the open/close state, and the <label> element acts as the clickable hamburger icon.
HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS-Only Navbar</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="nav-brand">
<a href="/" class="brand-link">AiTechWorlds</a>
</div>
<!-- Hidden checkbox — the state machine -->
<input type="checkbox" id="nav-toggle" class="nav-toggle" aria-hidden="true">
<!-- Hamburger label — becomes the button -->
<label for="nav-toggle" class="nav-hamburger" aria-label="Toggle navigation menu">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</label>
<!-- The nav menu -->
<ul class="nav-menu" role="list">
<li><a href="/" class="nav-link">Home</a></li>
<li><a href="/posts" class="nav-link">Blog</a></li>
<li><a href="/about" class="nav-link">About</a></li>
<li><a href="/contact" class="nav-link">Contact</a></li>
</ul>
</nav>
</body>
</html>
CSS — Checkbox Hack Version
/* Reset and base styles */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
}
/* Navbar container */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
height: 64px;
background-color: #1e293b;
color: white;
position: relative;
}
/* Brand */
.brand-link {
color: white;
text-decoration: none;
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.02em;
}
/* Hide the actual checkbox */
.nav-toggle {
display: none;
}
/* Hamburger icon — hidden on desktop */
.nav-hamburger {
display: none;
flex-direction: column;
justify-content: space-between;
width: 28px;
height: 20px;
cursor: pointer;
padding: 2px 0;
}
.hamburger-line {
display: block;
height: 2px;
width: 100%;
background-color: white;
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* Desktop nav menu — horizontal */
.nav-menu {
display: flex;
list-style: none;
gap: 32px;
}
.nav-link {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
font-size: 0.95rem;
transition: color 0.2s;
}
.nav-link:hover {
color: white;
}
/* ---- Mobile styles ---- */
@media (max-width: 768px) {
/* Show hamburger icon on mobile */
.nav-hamburger {
display: flex;
}
/* Stack the menu vertically, hide it by default */
.nav-menu {
display: flex;
flex-direction: column;
gap: 0;
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: #1e293b;
padding: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease, padding 0.35s ease;
}
.nav-menu li {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-link {
display: block;
padding: 16px 24px;
}
/* When checkbox is checked — open the menu */
.nav-toggle:checked ~ .nav-menu {
max-height: 400px;
padding-bottom: 8px;
}
/* Animate hamburger → X when open */
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(1) {
transform: translateY(9px) rotate(45deg);
}
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(2) {
opacity: 0;
}
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(3) {
transform: translateY(-9px) rotate(-45deg);
}
}
The ~ (general sibling combinator) is the key. When #nav-toggle:checked, we can style any sibling that comes after it in the DOM — which is why the checkbox must appear before the hamburger label and the nav menu in the HTML.
For more on CSS selectors and how combinators work, see the CSS selectors specificity guide.
Approach 2: The Modern CSS :has() Method
CSS :has() is a "parent selector" — it lets you style an element based on what it contains. We can use it to detect whether our checkbox is checked without the DOM order dependency.
This approach is cleaner because the HTML structure is more natural — you don't need the checkbox to precede the elements it controls.
HTML — :has() Version
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="nav-brand">
<a href="/" class="brand-link">AiTechWorlds</a>
</div>
<label for="nav-toggle-v2" class="nav-hamburger" aria-label="Toggle navigation">
<input type="checkbox" id="nav-toggle-v2" class="nav-toggle">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</label>
<ul class="nav-menu" role="list">
<li><a href="/" class="nav-link">Home</a></li>
<li><a href="/posts" class="nav-link">Blog</a></li>
<li><a href="/about" class="nav-link">About</a></li>
<li><a href="/contact" class="nav-link">Contact</a></li>
</ul>
</nav>
Notice the checkbox is now inside the label (not before it in the DOM). This makes more semantic sense — the label contains both the checkbox and the visual indicator.
CSS — :has() Version
/* Same base styles as before, plus: */
@media (max-width: 768px) {
.nav-hamburger {
display: flex;
position: relative;
z-index: 10;
}
.nav-toggle {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
margin: 0;
}
.nav-menu {
display: flex;
flex-direction: column;
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: #1e293b;
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease;
}
/* :has() magic — detect checked state from parent */
.navbar:has(.nav-toggle:checked) .nav-menu {
max-height: 400px;
}
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(2) {
opacity: 0;
}
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(3) {
transform: translateY(9px) rotate(45deg);
}
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(4) {
transform: translateY(-9px) rotate(-45deg);
}
}
The selector .navbar:has(.nav-toggle:checked) reads: "style the .navbar element when it has a .nav-toggle in a checked state somewhere inside it." Much more readable than the sibling combinator approach, and the HTML structure isn't constrained by CSS requirements.
The CSS Flexbox and Grid notes have more on building nav layouts if you want to extend this structure.
Comparison: Checkbox Hack vs CSS :has()
| Feature | Checkbox Hack | CSS :has() |
|---|---|---|
| Browser support | ~99.8% | ~92% |
| HTML order dependency | Yes (checkbox must come first) | No |
| Code readability | Moderate | Better |
| Animation capability | Full | Full |
| Accessibility | Needs ARIA attributes | Needs ARIA attributes |
| IE support | Works | No |
| Best for | Maximum compatibility | Modern browsers |
My recommendation: use :has() for personal projects and new builds targeting modern browsers. Use the checkbox hack if you need to support older browsers or are unsure of the browser matrix.
Accessibility: The Honest Notes
Both approaches have the same accessibility limitation: the checkbox state isn't announced properly to screen readers by default. Here's how to improve that:
<!-- Better accessible version -->
<label
for="nav-toggle"
class="nav-hamburger"
role="button"
aria-expanded="false"
aria-controls="main-nav-menu"
>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<span class="hamburger-line" aria-hidden="true"></span>
<span class="hamburger-line" aria-hidden="true"></span>
<span class="hamburger-line" aria-hidden="true"></span>
<span class="sr-only">Toggle menu</span>
</label>
<ul id="main-nav-menu" class="nav-menu">
<!-- ... -->
</ul>
/* Screen-reader only text — visually hidden but announced */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
The aria-expanded attribute should ideally update dynamically — which requires JavaScript. The CSS-only approach cannot update aria-expanded since that requires DOM manipulation. For a fully accessible menu, a few lines of JavaScript for state management is the right call:
// Minimal JS just for accessibility state
const toggle = document.querySelector('.nav-toggle');
const label = toggle.closest('label') || document.querySelector('[for="nav-toggle"]');
toggle.addEventListener('change', () => {
label.setAttribute('aria-expanded', toggle.checked);
});
This keeps the visual behavior in CSS while adding the accessibility layer with minimal JavaScript. MDN's ARIA authoring practices cover navigation landmarks in detail.
Adding a Dark Mode Support
Since we're keeping it clean, let's add dark mode support:
@media (prefers-color-scheme: dark) {
.navbar {
background-color: #0f172a;
}
.nav-link {
color: rgba(255, 255, 255, 0.7);
}
.nav-link:hover {
color: #60a5fa;
}
}
One media query and the navbar adapts to the user's system preference automatically.
Complete Production-Ready Version
Here's the full, clean CSS for both techniques combined with a fallback:
/*
Responsive CSS-only Navbar
Technique: :has() with checkbox hack fallback
*/
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem;
height: 4rem;
background: #1e293b;
position: sticky;
top: 0;
z-index: 100;
}
.brand-link {
color: #fff;
font-weight: 700;
font-size: 1.2rem;
text-decoration: none;
}
.nav-toggle { display: none; }
.nav-hamburger {
display: none;
flex-direction: column;
gap: 5px;
cursor: pointer;
padding: 4px;
}
.hamburger-line {
display: block;
height: 2px;
width: 24px;
background: #fff;
border-radius: 2px;
transition: transform 0.3s, opacity 0.3s;
transform-origin: center;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.nav-link:hover,
.nav-link:focus { color: #fff; outline: 2px solid #60a5fa; outline-offset: 2px; }
@media (max-width: 768px) {
.nav-hamburger { display: flex; }
.nav-menu {
flex-direction: column;
position: absolute;
top: 4rem; left: 0; right: 0;
background: #1e293b;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
gap: 0;
}
.nav-link { display: block; padding: 1rem 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
/* Checkbox hack approach */
.nav-toggle:checked ~ .nav-menu { max-height: 20rem; }
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(2) { opacity: 0; transform: scaleX(0); }
.nav-toggle:checked ~ .nav-hamburger .hamburger-line:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* :has() approach — overrides above when supported */
@supports selector(:has(*)) {
.navbar:has(.nav-toggle:checked) .nav-menu { max-height: 20rem; }
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(2) { opacity: 0; }
.navbar:has(.nav-toggle:checked) .hamburger-line:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
}
}
The @supports selector(:has(*)) block is feature detection — browsers that support :has() use the cleaner approach; others fall back to the sibling combinator. Best of both worlds.
For the JavaScript patterns that complement this, the JavaScript ES6 cheatsheet and web dev roadmap 2026 have related coverage.
Conclusion
A CSS-only navbar is a real, practical choice for many projects — especially static sites, documentation pages, and landing pages where you want to minimize JavaScript dependencies. The checkbox hack is battle-tested. The :has() approach is cleaner and where CSS is heading.
Use both together with @supports for the smoothest compatibility story. Add the minimal ARIA JavaScript snippet for accessibility. And remember that pure CSS solutions have limits — for mega menus, multi-level dropdowns, or complex focus management, a lightweight JavaScript solution is the right call.
The code above is production-ready for most use cases. Start with it, customize the colors and sizing to match your design, and build from there.
FAQs
Is a pure CSS hamburger menu accessible? The checkbox hack approach has accessibility limitations — screen readers may not announce the menu state correctly. For production use, add aria-expanded, aria-controls, and role='navigation' attributes. The CSS :has() approach is slightly better but still benefits from ARIA attributes.
Does CSS :has() work in all browsers? CSS :has() has about 92% global browser support as of 2026, with full support in Chrome 105+, Firefox 121+, and Safari 15.4+. For the remaining 8%, the checkbox fallback technique still works well.
When should I use JavaScript for a navbar instead of CSS? Use JavaScript when you need: keyboard trap management for accessibility, focus management, animations that CSS can't handle, multi-level dropdown menus, or when you need to track open/close state in your application logic.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use CSS Container Queries (Complete Tutorial 2026)
CSS container queries let components respond to their container size, not the viewport. Complete tutorial with syntax, real examples, browser support, and polyfills.
CSS Grid vs Flexbox: When to Use Which (With Cheatsheet)
CSS Grid or Flexbox? This guide breaks down when to use each layout method, with code examples, a decision table, and combined patterns.
How to Build a Dark Mode Toggle With CSS and JavaScript
Build a dark mode toggle with CSS variables, localStorage persistence, and prefers-color-scheme support. Full code, accessibility tips, and framework comparison included.
10 UI Design Principles Every Web Developer Should Know
Master the 10 core UI design principles that transform average interfaces into intuitive, polished experiences—with CSS code examples for each.