A navigation bar is the classic flexbox problem: a logo on one side, a group of links, and an action button that should sit at the far end. Flexbox solves it in a handful of lines, and the responsive version is a matter of deciding when the links should collapse.
What is the structure?
<header class="site-header">
<a class="logo" href="/">Acme</a>
<nav class="nav" aria-label="Primary">
<ul class="nav-list">
<li><a href="/docs">Docs</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/pricing">Pricing</a></li>
</ul>
</nav>
<a class="cta" href="/signup">Sign up</a>
<button class="menu-toggle" aria-expanded="false" aria-controls="primary-menu">
Menu
</button>
</header>
Three regions: the logo, the link list, and the call to action. The nav element with a label is what turns a group of links into a landmark, and the aria-expanded on the toggle is what a screen reader needs to know whether the menu is open.
How does flexbox lay it out?
.site-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
}
.logo {
font-weight: 700;
font-size: 1.25rem;
}
.nav {
flex: 1; /* takes the space between the logo and the button */
}
.nav-list {
display: flex;
gap: 1.25rem;
list-style: none;
margin: 0;
padding: 0;
}
The trick is the flex: 1 on the nav. The logo takes its natural width, the navigation takes the remaining space, and the call to action is pushed to the end by that. Without the growing nav, all three would sit together at the left with empty space after them.
The link list is a flex container too, because the links should sit in a row with a consistent gap and because that gives you a clean place to set the spacing. The list markers are removed with list-style: none while keeping the list element itself, so the navigation is still announced as a list of links.
How does it work on a small screen?
The most robust version wraps rather than hiding. The header becomes a wrapping flex container, and the link list takes the full width on a second line when the screen is narrow.
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.5rem;
}
.nav {
flex: 1 1 100%; /* on a narrow screen the links take a full row */
}
@media (min-width: 48rem) {
.nav {
flex: 1 1 auto; /* on a wide screen they sit inline */
order: 0;
}
}
Two details make this work. The flex-basis: 100% means the navigation wants a whole row, and the media query lets it shrink back to its natural width on a wide screen. And the ordering is handled with order rather than by moving the markup, so the DOM order stays sensible for screen readers.
How does the toggle button work?
A button that toggles a class on the header, with the aria-expanded attribute updated to match. The CSS then shows or hides the list.
<button class="menu-toggle" aria-expanded="false" aria-controls="primary-menu">Menu</button>
<nav class="nav" id="primary-menu">...</nav>
const toggle = document.querySelector('.menu-toggle');
const header = document.querySelector('.site-header');
toggle.addEventListener('click', () => {
const open = header.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
.menu-toggle { display: inline-flex; }
@media (min-width: 48rem) {
.menu-toggle { display: none; }
}
/* the small-screen menu, hidden until the toggle is pressed */
.nav-list {
display: none;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.site-header.is-open .nav-list {
display: flex;
}
The button is hidden on wide screens with a media query, and the list is hidden on small screens until the class is toggled. The important part is that aria-expanded is updated in the same handler as the class, so the state the screen reader announces always matches what is on screen.
What makes it feel finished?
Three details. A focus-visible style on the links, so keyboard users can see where they are. Enough vertical padding on the links to make them comfortable to tap — a link with no padding is a link with a tiny target. And white-space: nowrap on the logo, so a two-word brand does not wrap in the middle.
.nav-list a {
padding: 0.5rem 0.25rem;
border-radius: 4px;
}
.nav-list a:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.logo {
white-space: nowrap;
}
How do you mark the current page?
A navigation that shows every page equally makes the visitor work out where they are. Two small additions fix that, and they matter for screen reader users as much as for everybody else.
The first is a class on the link for the page you are on, styled differently from the others. The second is the aria-current attribute, which is the accessible version of the same idea. It is not a visual style - it is a statement about the state of the link, and assistive technology announces it.
<nav class="site-nav" aria-label="Main">
<a href="/">Home</a>
<a href="/tutorials" aria-current="page">Tutorials</a>
<a href="/pricing">Pricing</a>
<a href="/docs">Docs</a>
</nav>.site-nav a {
padding: 0.5rem 0.75rem;
color: #33475b;
text-decoration: none;
border-radius: 6px;
}
.site-nav a:hover {
background: #e7f0f8;
}
/* the current page, marked with a class the server can set */
.site-nav a[aria-current="page"] {
background: #10243a;
color: #ffffff;
font-weight: 600;
}Using the aria-current attribute as the hook for the styling is the neat part. The attribute carries the meaning, and the CSS reads it like any other attribute selector, so the visual state and the accessible state can never drift apart. The class is still worth having for the cases where you need to style the current page differently in a way the attribute does not describe, such as a different link colour for a section you are inside.
The other thing a good navbar needs is a skip link before it, so a keyboard user can jump past the whole list. The navigation is usually the first thing in the document, and on a site with twenty links in it that is twenty tab stops before the content begins.
Quick reference
- Give the middle region
flex: 1to push the last item to the end. - Keep the link list as a list element and remove only the markers.
- For a wrapping navbar, use
flex-wrapand aflex-basis: 100%on the nav. - Update
aria-expandedin the same handler that toggles the class. - Hide the toggle on wide screens with a media query, not with JavaScript.
- Give links vertical padding so the tap target is big enough.
Try it yourself: build the navbar above with the logo, four links and a sign-up button, then narrow the window and watch the links wrap onto their own row. Add the toggle and confirm the aria-expanded attribute changes when it is pressed, and that the state is announced as open or closed.