
How to Build a Responsive Navigation Bar with Pure CSS
The navigation bar is the first component most visitors interact with, and it's also one of the first places developers reach for JavaScript. A hamburger button that toggles a menu open and closed feels like it needs a click handler. But between Flexbox, media queries, and newer HTML features like the Popover API, you can build a fully responsive navbar with no JavaScript at all.
In this tutorial, we'll build a navbar step by step: a horizontal layout on desktop that collapses into a toggleable menu on small screens. We'll start with semantic HTML, style the desktop version, add the mobile toggle using two different no-JS techniques, and finish with accessibility and polish.
What We're Building
Our navbar will have:
- A logo on the left.
- Navigation links in the middle or right.
- A call-to-action button.
- On screens narrower than 768px, a menu button that reveals the links in a dropdown panel.
Step 1: Semantic HTML
Start with the markup. Using the right elements gives you accessibility for free and makes styling easier.
<header class="site-header">
<a class="logo" href="/">TideWave</a>
<button
class="menu-toggle"
popovertarget="primary-nav"
aria-label="Open menu"
>
<span class="menu-icon" aria-hidden="true"></span>
</button>
<nav class="primary-nav" id="primary-nav" popover aria-label="Primary">
<ul class="nav-list">
<li><a href="/blog" aria-current="page">Blog</a></li>
<li><a href="/tutorials">Tutorials</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<a class="nav-cta" href="/subscribe">Subscribe</a>
</nav>
</header>
A few notes on the choices here:
headerandnavare landmarks that screen reader users can jump to directly. Thearia-labelonnavdistinguishes it from other navigation regions, like a footer menu.- Links live inside a
ul, which tells assistive technology how many items there are. aria-current="page"marks the current page, and we can style it with an attribute selector.- The
popoverattribute andpopovertargetare the key to the no-JavaScript toggle. We'll explain them in Step 3.
Step 2: Base Styles and the Desktop Layout
Let's style the navbar for wide screens first, then adapt it for mobile. We'll set up a few custom properties to keep things consistent.
:root {
--nav-bg: #0f172a;
--nav-text: #e2e8f0;
--nav-accent: #38bdf8;
--nav-height: 4rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 2rem;
height: var(--nav-height);
padding-inline: 1.5rem;
background: var(--nav-bg);
color: var(--nav-text);
}
.logo {
font-weight: 700;
font-size: 1.25rem;
color: #ffffff;
text-decoration: none;
}
position: sticky keeps the header at the top of the viewport as the page scrolls, without removing it from the document flow like position: fixed would.
Now the nav itself. On desktop, we want the nav to behave like a normal flex row, not a popover. By default, an element with the popover attribute is hidden until opened, so we need to override that for wide screens:
.menu-toggle {
display: none;
}
.primary-nav {
/* Reset popover defaults on desktop */
display: flex;
align-items: center;
gap: 2rem;
flex: 1;
position: static;
inset: auto;
width: auto;
height: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
overflow: visible;
}
.nav-list {
display: flex;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.nav-list a {
display: block;
padding: 0.5rem 0.875rem;
border-radius: 6px;
color: var(--nav-text);
text-decoration: none;
transition:
background-color 150ms ease,
color 150ms ease;
}
.nav-list a:hover,
.nav-list a:focus-visible {
background: rgb(255 255 255 / 0.08);
color: #ffffff;
}
.nav-list a[aria-current="page"] {
color: var(--nav-accent);
}
.nav-cta {
margin-left: auto;
padding: 0.5rem 1rem;
border-radius: 999px;
background: var(--nav-accent);
color: #0f172a;
font-weight: 600;
text-decoration: none;
}
The margin-left: auto on the call-to-action pushes it to the far right, while the links sit next to the logo. display: flex on .primary-nav overrides the browser's default display: none for closed popovers, so the nav is always visible on desktop.
Step 3: The Mobile Menu with the Popover API
On narrow screens, we'll show the menu button and turn the nav into a dropdown panel.
The Popover API is an HTML feature. Any element with the popover attribute is hidden by default and shown in the browser's top layer (above everything else, no z-index needed) when toggled. A button with popovertarget="primary-nav" toggles the element with that id on click. The browser also handles closing it when the user presses Escape or clicks outside ("light dismiss"), and it manages focus sensibly. All of that without a single line of JavaScript.
The Popover API is supported in current versions of all major browsers.
@media (max-width: 767px) {
.site-header {
justify-content: space-between;
}
.menu-toggle {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--nav-text);
cursor: pointer;
}
.menu-toggle:focus-visible {
outline: 2px solid var(--nav-accent);
outline-offset: 2px;
}
/* Hamburger icon drawn with CSS */
.menu-icon,
.menu-icon::before,
.menu-icon::after {
display: block;
width: 22px;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.menu-icon {
position: relative;
}
.menu-icon::before,
.menu-icon::after {
content: "";
position: absolute;
left: 0;
}
.menu-icon::before {
top: -7px;
}
.menu-icon::after {
top: 7px;
}
/* The nav becomes a popover panel */
.primary-nav {
position: fixed;
inset: var(--nav-height) 0 auto 0;
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 1rem 1.5rem 1.5rem;
background: var(--nav-bg);
border-top: 1px solid rgb(255 255 255 / 0.1);
}
/* Closed popovers are hidden on mobile */
.primary-nav:not(:popover-open) {
display: none;
}
.nav-list {
flex-direction: column;
}
.nav-list a {
padding: 0.875rem 0.5rem;
font-size: 1.125rem;
}
.nav-cta {
margin-left: 0;
text-align: center;
}
}
The important selector is .primary-nav:not(:popover-open). On desktop we forced display: flex, so the nav is always visible. On mobile, we hide it again unless the popover is open. When the user taps the button, the browser adds the element to the top layer, :popover-open matches, and the panel appears just below the header.
Showing the open state on the button
It's nice to turn the hamburger into an "X" when the menu is open. We can use :has() on the header to detect the open popover:
@media (max-width: 767px) {
.menu-icon,
.menu-icon::before,
.menu-icon::after {
transition:
transform 200ms ease,
background-color 200ms ease;
}
.site-header:has(.primary-nav:popover-open) .menu-icon {
background: transparent;
}
.site-header:has(.primary-nav:popover-open) .menu-icon::before {
transform: translateY(7px) rotate(45deg);
}
.site-header:has(.primary-nav:popover-open) .menu-icon::after {
transform: translateY(-7px) rotate(-45deg);
}
}
The middle bar fades out, and the top and bottom bars rotate into an X. :has() is supported in all current major browsers.
Animating the panel open
For a smooth slide-down effect, combine transitions with @starting-style and allow-discrete:
@media (max-width: 767px) {
.primary-nav {
transition:
opacity 200ms ease,
translate 200ms ease,
overlay 200ms allow-discrete,
display 200ms allow-discrete;
}
.primary-nav:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
.primary-nav:popover-open {
opacity: 0;
translate: 0 -0.5rem;
}
}
.primary-nav:not(:popover-open) {
opacity: 0;
translate: 0 -0.5rem;
}
}
@starting-style defines the values to transition from when the element first appears, and allow-discrete lets display and overlay wait until the fade-out finishes before hiding. These features are supported in Chromium-based browsers and Safari, and Firefox has been adding support; where they aren't supported, the menu simply opens and closes instantly, which is a perfectly good fallback.
Alternative: The Checkbox Technique
Before the Popover API, the classic no-JS approach used a hidden checkbox and a label as the toggle. You'll still see it in plenty of codebases, so it's worth knowing:
<header class="site-header">
<a class="logo" href="/">TideWave</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
<label for="nav-toggle" class="nav-toggle-label">Menu</label>
<nav class="primary-nav" aria-label="Primary">
<ul class="nav-list">
...
</ul>
</nav>
</header>
.nav-toggle {
position: absolute;
opacity: 0;
pointer-events: none;
}
.nav-toggle-label {
display: none;
}
@media (max-width: 767px) {
.nav-toggle-label {
display: block;
cursor: pointer;
}
.primary-nav {
display: none;
}
.nav-toggle:checked ~ .primary-nav {
display: block;
}
.nav-toggle:focus-visible + .nav-toggle-label {
outline: 2px solid #38bdf8;
}
}
It works everywhere, but it has real drawbacks. Screen readers announce a checkbox rather than a menu button, Escape doesn't close it, clicking outside doesn't close it, and the menu state isn't communicated with aria-expanded. The Popover API solves all of those issues natively, so prefer it for new projects.
Step 4: Accessibility Checklist
A navbar is only as good as its accessibility. Run through this list:
- Keyboard navigation. Tab through the header. Every link and the toggle button should be reachable, with a clearly visible focus style. Don't remove outlines without replacing them.
- Touch targets. Aim for at least 44 by 44 pixels on mobile. That's why the mobile links have generous vertical padding.
- Labels. The toggle button needs an accessible name.
aria-label="Open menu"works; so does visually hidden text inside the button. - Current page. Use
aria-current="page"rather than just a color change, so assistive technology knows which link is active. - Reduced motion. Respect users who prefer less animation:
@media (prefers-reduced-motion: reduce) {
.primary-nav,
.menu-icon,
.menu-icon::before,
.menu-icon::after {
transition: none;
}
}
- Color contrast. Light text on a dark background needs a contrast ratio of at least 4.5:1 for normal-size text. The colors used here (
#e2e8f0on#0f172a) comfortably pass.
Step 5: Polish and Variations
Adding a subtle shadow on scroll
With scroll-driven animations, you can add a shadow once the page scrolls, without JavaScript:
@supports (animation-timeline: scroll()) {
.site-header {
animation: header-shadow linear both;
animation-timeline: scroll();
animation-range: 0 80px;
}
@keyframes header-shadow {
to {
box-shadow: 0 6px 20px rgb(0 0 0 / 0.35);
}
}
}
Scroll-driven animations are supported in Chromium-based browsers and recent Safari, but not yet everywhere. The @supports wrapper means other browsers just get a header without the shadow.
Centered links
If you prefer the links centered between the logo and the button, change .primary-nav to a three-part layout with Grid on desktop:
@media (min-width: 768px) {
.site-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
}
.primary-nav {
display: contents;
}
.nav-list {
justify-self: center;
}
.nav-cta {
justify-self: end;
}
}
display: contents makes the nav's children act as direct grid items of the header. Use it with care: it has historically caused some browsers to drop the element's landmark role from the accessibility tree. Recent browser versions have fixed most of these issues for common elements, but test with a screen reader if you rely on it for a nav.
Common Pitfalls
Forgetting to reset popover styles. The browser's default popover styles include a border, padding, a fixed position with margin: auto, a fit-content width and height, and display: none when closed. If your desktop nav looks strangely centered or invisible, you haven't overridden them.
Using display: none for the toggle without a replacement. On desktop, hiding the menu button is fine because the links are visible. On mobile, make sure the button is visible and focusable.
Relying on hover for submenus. Hover doesn't exist on touch screens. If you add dropdown submenus, make sure they can be opened with a tap and with the keyboard; details/summary or nested popovers are good options.
Hardcoding the header height in many places. Use a custom property like --nav-height so the mobile panel's position stays in sync with the header.
Conclusion
A responsive navigation bar doesn't need a framework or a click handler. Semantic HTML gives you landmarks and structure, Flexbox handles the desktop layout, a media query switches to the mobile version, and the Popover API provides a toggle with Escape-to-close, light dismiss, and top-layer rendering built in. Add :has() to animate the hamburger icon, @starting-style for a smooth entrance, and a quick accessibility pass, and you have a production-quality navbar in plain HTML and CSS.
The checkbox technique still works if you need it, but for new projects, the popover-based approach is more accessible and behaves the way users expect a menu to behave.


