Skip to main content
ASoc
Tutorial

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.

The ASoc Team7 min read

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:

RequirementWhere it goes wrongHow src/components/organisms/Header.tsx handles it
Links are real anchorsdiv with onClick is invisible to crawlers and keyboards<Link> for every route, inside <nav aria-label="Primary">
Toggle announces its stateScreen reader hears "button" and nothing elsearia-expanded={navOpen} and a label that flips between "Open menu" and "Close menu"
Page behind the drawer stays stillBody scrolls under the open menuEffect sets document.body.style.overflow = "hidden" and restores the previous value
Escape closes itKeyboard user is stuckkeydown listener, added only while open
Closed menu is not tabbableOff-screen links stay in the tab orderinvisible on the closed drawer
Tapping a link closes itDrawer stays over the new pageOne click handler on the drawer that checks closest("a")
One breakpointTwo navbars to keep in syncThe 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

SymptomCauseFix
Page scrolls behind the open menuNo scroll lockSet body.style.overflow = "hidden" in an effect and restore it in cleanup
Scroll lock sticks after navigating awayCleanup missing, or the component unmounted while openRestore in the effect's return function, as above
Tab reaches links you cannot seeClosed drawer only pointer-events-none or off-screenAdd invisible, and include visibility in transition-[...]
Drawer pops instead of slidinginvisible applied immediatelytransition-[transform,visibility]
Menu stays open after clicking a linkNo close on navigationClose from a click handler that finds closest("a")
Hydration warning on the signed-in buttonServer renders signed out, client updates after mountStart isSignedIn as false and set it in an effect
Desktop links disappear at xlThe xl:visible override is missingThe 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.

Keep reading

Tutorial8 min read

What's Actually in a Data Dashboard? 59 Views, Counted

9 admin templates, 59 dashboard views, audited from the catalog — what a coded data dashboard actually contains versus a BI tool.

Read more
Tutorial12 min read

DB Schema: Two Meanings, Six Tables, and 271 Lines of Postgres

In Postgres “schema” means both the structure and a namespace. Here is a real commerce schema — 6 tables, 8 migrations, and the four times it had to change.

Read more