Overview
Every real website needs a navigation bar that works on both a wide desktop monitor and a narrow phone screen, and the way it adapts between the two is one of the first real responsive-design problems a CSS learner runs into. This project builds exactly that: a header with a logo and a row of links that lays itself out horizontally with Flexbox on wide screens, then collapses into a hamburger-triggered dropdown menu once the viewport gets too narrow for the full row to fit comfortably.
The interesting constraint on a CSS-only course is that a hamburger menu normally needs JavaScript to toggle open and closed. This project solves that with the classic "checkbox hack": a hidden checkbox holds the open/closed state, a `<label>` styled as the hamburger icon toggles that checkbox when clicked, and a CSS sibling selector reveals the menu whenever the checkbox is checked. No JavaScript is written anywhere in this project — the interactivity is pure CSS.
- A dark header with a logo on the left and nav links on the right, laid out with Flexbox.
- A styled call-to-action link that stands out from the plain nav links.
- A hamburger icon built from three `<span>` bars, hidden on desktop and shown on mobile.
- A checkbox-hack toggle that opens and closes the mobile menu with zero JavaScript.
- A media query that switches the whole layout from a horizontal row to a stacked dropdown.
- A sticky header that stays pinned to the top of the page while scrolling.
Prerequisites
- The CSS box model — margin, padding, border, and how `box-sizing` affects sizing.
- Flexbox basics — `display: flex`, `justify-content`, `align-items`, and `flex-direction`.
- CSS selectors — class selectors, descendant selectors, and the `:hover` and `:checked` pseudo-classes.
- Media queries — the `@media (max-width: ...)` syntax for applying styles below a given viewport width.
- Basic HTML — forms elements like `<input>` and `<label>`, and how a `<label for="...">` links to an `<input>`'s `id`.
Project Structure
This is a CSS project, so the HTML markup is written once and never changes — every step from here on only adds or refines CSS rules. The markup below is deliberately minimal: a `<header>` containing the logo, the hidden checkbox, its label styled as the hamburger icon, and a `<nav>` wrapping the link list. Notice the order of elements — the checkbox, its label, and the menu `<nav>` are all siblings inside `.navbar`, which is what makes the CSS sibling-selector trick in Step 4 possible.
<!-- Site header: everything the navbar needs lives inside this one element --><header class="site-header"> <div class="navbar"> <a href="#" class="navbar__logo">DevSite</a>
<!-- Checkbox-hack: this checkbox is never visually shown, but its checked state is what Step 4/5's CSS uses to know whether the menu is open --> <input type="checkbox" id="nav-toggle" class="nav-toggle">
<!-- Clicking anywhere on this label toggles the checkbox above, because its "for" attribute matches the checkbox's "id" --> <label for="nav-toggle" class="nav-toggle-label"> <span></span> <span></span> <span></span> </label>
<!-- Must come AFTER .nav-toggle in the markup for the ~ sibling selector to work --> <nav class="navbar__menu"> <ul class="navbar__links"> <li><a href="#" class="navbar__link">Home</a></li> <li><a href="#" class="navbar__link">Courses</a></li> <li><a href="#" class="navbar__link">Projects</a></li> <li><a href="#" class="navbar__link">About</a></li> <li><a href="#" class="navbar__link navbar__link--cta">Sign Up</a></li> </ul> </nav> </div></header>
<!-- Some page content underneath, just so the header has something to sit above --><main class="page-content"> <h1>Welcome to DevSite</h1> <p>Shrink the browser (or this preview) below tablet width to see the navbar collapse into a hamburger menu.</p></main>Keep this exact markup in mind for every step below — nothing is added or removed from it later, only the CSS applied to it changes.
Step 1: Reset Defaults and Style the Header Shell
Every browser ships its own default margins, paddings, and box-sizing behavior, so the first rule in almost any CSS project is a small reset that puts every browser on the same footing. After that, we give the header its dark background and set up `.navbar` as a centered, padded container for its contents.
/* Reset default spacing so the layout starts from a predictable baseline in every browser */* { margin: 0; /* Browsers apply default margins to headings, lists, etc. — remove them all */ padding: 0; /* Same idea for default padding, e.g. on <ul> */ box-sizing: border-box; /* Width/height include padding and border instead of adding on top of them */}
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; /* Safe, native-looking font stack */ color: #1f2937; /* Dark slate for body text, easier on the eyes than pure black */ background: #f8fafc; /* Very light gray page background behind the dark header */ line-height: 1.5; /* Comfortable reading line height for paragraph text */}
.site-header { background: #111827; /* Dark navy-black header background, contrasts with the light page body */ color: #ffffff; /* White text/icons read clearly against the dark background */}
.navbar { max-width: 1100px; /* Stops the navbar content from stretching edge-to-edge on very wide screens */ margin: 0 auto; /* Centers the navbar horizontally within the full-width header */ padding: 1rem 1.5rem; /* Breathing room around the logo and links */}Step 2: Arrange the Desktop Navbar with Flexbox
With the shell in place, Flexbox is what turns the logo and the link list into a single horizontal row. `justify-content: space-between` is doing the heavy lifting here — it pushes the logo to the far left and the link list to the far right, with any leftover space distributed between them.
.navbar { /* ...existing rules from Step 1 stay... */ display: flex; /* Turns .navbar into a flex container, laying its children in a row */ align-items: center; /* Vertically centers the logo against the (taller) link list */ justify-content: space-between; /* Logo pinned left, nav menu pinned right, gap in between */}
.navbar__logo { font-size: 1.25rem; font-weight: 700; color: #ffffff; text-decoration: none; /* Anchor tags are underlined by default; the logo shouldn't look like a link */}
.navbar__links { display: flex; /* Lays every <li> out horizontally instead of the default stacked list */ align-items: center; /* Keeps all the links vertically aligned with each other */ list-style: none; /* Removes the bullet point every <li> gets by default */ gap: 1.75rem; /* Even spacing between links, without needing margin on each individual <li> */}Step 3: Style the Nav Links and CTA Button
The plain links need a hover state so the navbar feels interactive, and the last link ("Sign Up") is styled as a distinct call-to-action button rather than a plain text link, so it draws the eye as the primary action in the header.
.navbar__link { color: #d1d5db; /* Light gray — readable, but visually secondary to the white logo */ text-decoration: none; font-size: 0.95rem; font-weight: 500; transition: color 0.2s ease; /* Animates the color change on hover instead of an instant snap */}
.navbar__link:hover { color: #ffffff; /* Brightens to full white on hover, signaling that the link is interactive */}
.navbar__link--cta { background: #6366f1; /* Accent color makes this link visually distinct from the plain-text links */ color: #ffffff; padding: 0.5rem 1rem; border-radius: 6px; /* Softly rounded corners give it a button-like shape */}
.navbar__link--cta:hover { background: #4f46e5; /* Slightly darker accent on hover, a different hover treatment than plain links */ color: #ffffff; /* Reasserted so it doesn't fall back to the plain .navbar__link hover color */}Step 4: Build the Hamburger Icon with the Checkbox Hack
This is the core CSS-only trick of the project. The `<input type="checkbox">` from the markup is completely hidden with `display: none` — but hiding it does not disable it, it can still be toggled by clicking its `<label>`, and CSS can still read its `:checked` state. The label itself is styled to look like three horizontal bars, which is the universal hamburger icon shape.
/* The checkbox itself is never meant to be visible — only its checked STATE matters to the CSS below */.nav-toggle { display: none;}
.nav-toggle-label { display: none; /* Hidden for now — Step 5's media query reveals it only on small screens */ flex-direction: column; /* Stacks the three bar <span>s vertically inside the label */ justify-content: space-between; width: 24px; height: 18px; cursor: pointer; /* Shows a pointer cursor so it visually reads as clickable, like a button */}
.nav-toggle-label span { display: block; height: 2px; /* Each bar is a thin horizontal line */ width: 100%; background: #ffffff; /* White bars stand out against the dark header background */ border-radius: 2px; transition: transform 0.25s ease, opacity 0.25s ease; /* Animates the open-state "X" morph added below */}
/* Sibling selector: when the checkbox is :checked, style the <label>'s spans that follow it (~). This only works because the checkbox and its label are siblings in the HTML markup. */.nav-toggle:checked ~ .nav-toggle-label span:nth-child(1) { transform: translateY(8px) rotate(45deg); /* Top bar rotates down into one diagonal stroke of an X */}
.nav-toggle:checked ~ .nav-toggle-label span:nth-child(2) { opacity: 0; /* Middle bar disappears, leaving just the two diagonal strokes that form the X */}
.nav-toggle:checked ~ .nav-toggle-label span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); /* Bottom bar rotates up into the other diagonal stroke */}The `~` in `.nav-toggle:checked ~ .nav-toggle-label` is the general sibling combinator — it selects `.nav-toggle-label` only when it appears somewhere after `.nav-toggle:checked` under the same parent. That single relationship is the entire mechanism behind the checkbox hack: no JavaScript event listener is needed because CSS itself reacts to the checkbox's built-in checked/unchecked state.
Step 5: Reveal the Mobile Menu at Small Widths
Now the pieces come together inside a media query. Below 768px, the hamburger label is shown, the desktop link row is turned into an absolutely positioned dropdown, and the same `:checked ~` sibling trick from Step 4 is reused — this time to expand the dropdown's height from `0` to a value tall enough to show every link.
@media (max-width: 768px) { .nav-toggle-label { display: flex; /* Reveal the hamburger icon now that we're below tablet width */ }
.navbar__menu { position: absolute; /* Takes the menu out of normal flow so it overlays the page below the header */ top: 100%; /* Positioned directly under the header */ left: 0; right: 0; background: #111827; /* Matches the header background so the dropdown feels attached to it */ overflow: hidden; /* Clips the link list while max-height keeps it collapsed */ max-height: 0; /* Collapsed by default — this IS the "closed" state of the menu */ transition: max-height 0.3s ease; /* Animates opening/closing instead of an instant jump */ }
.navbar__links { flex-direction: column; /* Stack links vertically inside the dropdown instead of a horizontal row */ align-items: stretch; gap: 0; padding: 0.5rem 1.5rem; }
.navbar__link { padding: 0.75rem 0; /* Larger, easier-to-tap targets for touchscreens */ border-bottom: 1px solid rgba(255, 255, 255, 0.08); /* Subtle divider between each stacked link */ }
/* Reusing the checkbox-hack sibling selector: when checked, expand the (currently 0px) dropdown */ .nav-toggle:checked ~ .navbar__menu { max-height: 300px; /* Generously tall enough to fit every link, revealing the whole dropdown */ }}Step 6: Make the Header Sticky and Polish
The last step is a small usability upgrade — pinning the header to the top of the viewport with `position: sticky` so the navbar (and its menu toggle) stay reachable no matter how far the user scrolls — plus a little spacing for the page content underneath.
.site-header { position: sticky; /* Sticks to the top of its scroll container instead of scrolling away with the page */ top: 0; /* Pins exactly at the top edge of the viewport */ z-index: 100; /* Keeps the header (and its open dropdown) layered above the page content below it */}
.page-content { max-width: 1100px; /* Matches .navbar's max-width so page content and header stay visually aligned */ margin: 0 auto; padding: 3rem 1.5rem;}
.page-content h1 { margin-bottom: 0.75rem; font-size: 1.75rem;}Complete Code
Here is the fixed HTML markup again for reference, followed by the complete stylesheet with every rule from Steps 1 through 6 assembled in order.
<header class="site-header"> <div class="navbar"> <a href="#" class="navbar__logo">DevSite</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle"> <label for="nav-toggle" class="nav-toggle-label"> <span></span> <span></span> <span></span> </label>
<nav class="navbar__menu"> <ul class="navbar__links"> <li><a href="#" class="navbar__link">Home</a></li> <li><a href="#" class="navbar__link">Courses</a></li> <li><a href="#" class="navbar__link">Projects</a></li> <li><a href="#" class="navbar__link">About</a></li> <li><a href="#" class="navbar__link navbar__link--cta">Sign Up</a></li> </ul> </nav> </div></header>
<main class="page-content"> <h1>Welcome to DevSite</h1> <p>Shrink the browser (or this preview) below tablet width to see the navbar collapse into a hamburger menu.</p></main>* { margin: 0; padding: 0; box-sizing: border-box;}
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2937; background: #f8fafc; line-height: 1.5;}
.site-header { background: #111827; color: #ffffff; position: sticky; top: 0; z-index: 100;}
.navbar { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem; display: flex; align-items: center; justify-content: space-between;}
.navbar__logo { font-size: 1.25rem; font-weight: 700; color: #ffffff; text-decoration: none;}
.navbar__links { display: flex; align-items: center; list-style: none; gap: 1.75rem;}
.navbar__link { color: #d1d5db; text-decoration: none; font-size: 0.95rem; font-weight: 500; transition: color 0.2s ease;}
.navbar__link:hover { color: #ffffff;}
.navbar__link--cta { background: #6366f1; color: #ffffff; padding: 0.5rem 1rem; border-radius: 6px;}
.navbar__link--cta:hover { background: #4f46e5; color: #ffffff;}
.nav-toggle { display: none;}
.nav-toggle-label { display: none; flex-direction: column; justify-content: space-between; width: 24px; height: 18px; cursor: pointer;}
.nav-toggle-label span { display: block; height: 2px; width: 100%; background: #ffffff; border-radius: 2px; transition: transform 0.25s ease, opacity 0.25s ease;}
.nav-toggle:checked ~ .nav-toggle-label span:nth-child(1) { transform: translateY(8px) rotate(45deg);}
.nav-toggle:checked ~ .nav-toggle-label span:nth-child(2) { opacity: 0;}
.nav-toggle:checked ~ .nav-toggle-label span:nth-child(3) { transform: translateY(-8px) rotate(-45deg);}
.page-content { max-width: 1100px; margin: 0 auto; padding: 3rem 1.5rem;}
.page-content h1 { margin-bottom: 0.75rem; font-size: 1.75rem;}
@media (max-width: 768px) { .nav-toggle-label { display: flex; }
.navbar__menu { position: absolute; top: 100%; left: 0; right: 0; background: #111827; overflow: hidden; max-height: 0; transition: max-height 0.3s ease; }
.navbar__links { flex-direction: column; align-items: stretch; gap: 0; padding: 0.5rem 1.5rem; }
.navbar__link { padding: 0.75rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
.nav-toggle:checked ~ .navbar__menu { max-height: 300px; }}Sample Run
On a wide screen, the preview above shows the logo on the left and the horizontal link row (with the highlighted "Sign Up" button) on the right, sitting inside a dark sticky header. Narrow the browser window — or shrink this page — below 768px and the link row disappears, replaced by the three-bar hamburger icon on the right. Click it and the checkbox underneath toggles to `checked`, which expands `.navbar__menu`'s `max-height` and animates the dropdown open while the hamburger bars morph into an "X". Click again to close it — all without a single line of JavaScript.
Extend This Project
- Add a `prefers-color-scheme` media query so the navbar switches to a light theme automatically on light-mode systems.
- Make the logo swap to a smaller size on mobile using a media query, to leave more room for the hamburger icon.
- Add a dropdown submenu to one of the nav links using a nested `<ul>` and a `:hover`/focus-based reveal.
- Animate the hamburger icon's bars with a slightly longer, staggered `transition-delay` on each `<span>` for a more polished open/close feel.
- Add an `aria-label` to the `<label>` and test the menu with keyboard-only navigation (Tab and Enter) to make the checkbox hack accessible.
Summary
You built a fully responsive navigation bar that lays itself out with Flexbox on desktop and collapses into a hamburger-triggered dropdown on mobile — entirely in CSS. The checkbox-hack pattern you used here (a hidden checkbox, a label styled as a control, and a `:checked ~` sibling selector) is a reusable trick you will see again anywhere a small site needs simple, JavaScript-free interactivity: toggles, accordions, and tabs can all be built the same way.