Creating a Navbar in React: One Boolean, Two Effects, One Tab-Order Bug
A React navbar from a real 200-line header: aria-expanded toggle, scroll lock, Escape to close, and why pointer-events-none left links tabbable.
A React navbar needs three pieces of state-free markup and one piece of state: a <nav> with real links, a toggle button with aria-expanded, and a drawer that closes on link click, backdrop click and Escape. The only state is a boolean. The site header in this repo does all of it in 200 lines with one useState for the menu.
What the navbar actually has to do
Most tutorials stop at "render some links and a hamburger". The parts that break in production are quieter:
| Requirement | Where it goes wrong | How src/components/organisms/Header.tsx handles it |
|---|---|---|
| Links are real anchors | div with onClick is invisible to crawlers and keyboards | <Link> for every route, inside <nav aria-label="Primary"> |
| Toggle announces its state | Screen reader hears "button" and nothing else | aria-expanded={navOpen} and a label that flips between "Open menu" and "Close menu" |
| Page behind the drawer stays still | Body scrolls under the open menu | Effect sets document.body.style.overflow = "hidden" and restores the previous value |
| Escape closes it | Keyboard user is stuck | keydown listener, added only while open |
| Closed menu is not tabbable | Off-screen links stay in the tab order | invisible on the closed drawer |
| Tapping a link closes it | Drawer stays over the new page | One click handler on the drawer that checks closest("a") |
| One breakpoint | Two navbars to keep in sync | The same <nav> is the desktop bar and the mobile drawer |
The state: one boolean, two effects
// src/components/organisms/Header.tsx
export default function Header() {
const [navOpen, setNavOpen] = useState(false);
// ...
// Lock body scroll and let Escape close the mobile drawer while it's open.
useEffect(() => {
if (!navOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setNavOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [navOpen]);
Two details matter. The effect returns early when the menu is closed, so there is no listener on pages where nobody opened it. And it restores previousOverflow instead of writing "", so it does not clobber a scroll lock that something else (a modal, say) already set.
The header has a second effect, for the signed-in state. It has nothing to do with navigation, which is the point: it is isolated from the menu logic. It skips the auth client entirely when there is no session cookie, because this header renders on every page and the auth bundle was the largest script on pages with no account UI.
The toggle button
<button
aria-label={navOpen ? "Close menu" : "Open menu"}
aria-expanded={navOpen}
onClick={() => setNavOpen((o) => !o)}
className="ml-auto block"
type="button"
>
type="button" stops it submitting if the header is ever inside a form. Use the functional update (o) => !o so rapid taps cannot read a stale value. The icon is five absolutely-positioned bars that cross-fade between a hamburger and an X; the button label and aria-expanded carry the meaning, so the bars can stay decorative.
One <nav>, two layouts
The usual approach is two components: a desktop bar and a mobile drawer. This header keeps one set of links and moves it with classes. The container is a fixed, right-anchored panel below xl, and a static flex row from xl up:
className={`fixed inset-y-0 right-0 z-9999 flex w-[85%] max-w-xs flex-col ... xl:pointer-events-auto xl:visible xl:static xl:inset-auto xl:translate-x-0 xl:flex-row ... ${
navOpen
? "visible translate-x-0"
: "pointer-events-none invisible translate-x-full"
}`}
The closed state is translate-x-full plus invisible, and the xl: variants cancel both. One list of links means one place to add a page. The current list is four links: Templates, Pricing, Docs, Blog.
The bug worth copying the fix for
The first version of the closed drawer used pointer-events-none and translate-x-full, and that looked right: the menu slid off-screen and the mouse could not touch it. But pointer-events-none does not affect the Tab key. On a phone-width window, a keyboard user tabbed through four nav links parked off-screen before reaching the page.
The fix is the invisible in the closed branch, and visibility has to be in the transition list so it flips after the slide-out finishes:
transition-[transform,visibility] duration-300 ease-in-out
Without that, invisible applies instantly and the drawer vanishes instead of sliding. Test it yourself: open the page at 400px wide, close the menu, press Tab, and watch where focus goes.
Closing on navigation
Next.js (or React Router) swaps the page without unmounting the header, so the drawer would stay open over the new route. Rather than reading the pathname in an effect, the header closes it where the click happens:
<div
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) setNavOpen(false);
}}
>
One handler covers every link, including ones added later. The backdrop is a second element with its own onClick, marked aria-hidden because it is a pointer shortcut and Escape already covers keyboard users.
Using it with React Router
This repo uses next/link. In a plain React app, the structure is identical and only the link component changes:
import { NavLink } from "react-router-dom";
<NavLink to="/pricing" className={({ isActive }) => isActive ? "text-primary" : "text-text-color"}>
Pricing
</NavLink>
NavLink gives you the active-route class for free. For next/link, compare usePathname() yourself and set aria-current="page". This header does not highlight the current page, which is a known gap, not a recommendation.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Page scrolls behind the open menu | No scroll lock | Set body.style.overflow = "hidden" in an effect and restore it in cleanup |
| Scroll lock sticks after navigating away | Cleanup missing, or the component unmounted while open | Restore in the effect's return function, as above |
| Tab reaches links you cannot see | Closed drawer only pointer-events-none or off-screen | Add invisible, and include visibility in transition-[...] |
| Drawer pops instead of sliding | invisible applied immediately | transition-[transform,visibility] |
| Menu stays open after clicking a link | No close on navigation | Close from a click handler that finds closest("a") |
| Hydration warning on the signed-in button | Server renders signed out, client updates after mount | Start isSignedIn as false and set it in an effect |
Desktop links disappear at xl | The xl:visible override is missing | The same element is both the bar and the drawer, so the xl: classes must undo every closed-state class |
Frequently asked questions
Do I need a library like Headless UI for a navbar? Not for a link list and a drawer. One boolean, an Escape listener and correct ARIA cover it. Reach for a library when you need nested menus, roving focus or a focus trap, which is where hand-rolled code gets long. See React focus trap for that case.
Should the navbar be a server component?
The links could be, but the toggle needs state, so the header is a client component ("use client" at the top). If bundle size matters, split the static links into a server component and keep only the toggle on the client.
Hamburger icon with SVG or CSS bars?
Either works. This header uses CSS bars and puts the meaning in aria-label and aria-expanded. If you use an icon, give it aria-hidden so the label is not read twice.
How do I build a dropdown mega menu?
Add aria-expanded and aria-controls to each trigger and handle Escape and focus return. The accessible mega menu walkthrough builds that on top of this pattern. The "Resources" dropdown that used to sit in this header was removed because its only unique entry was Blog.
Templates in this post
ASoc Vault (a fintech SaaS site), ASoc Vox (an AI voiceover landing page) and ASoc Weave (an AI website builder site) ship a header built on this pattern.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the plain HTML version of the same nav, see HTML and CSS nav bar.
