Building an HTML/CSS Navigation Bar (and the Bug That Hides It)
Six ingredients build an accessible nav bar — and this site's own mobile drawer shipped the pointer-events-none bug that leaves closed links keyboard-reachable.
A navigation bar is a <nav> landmark wrapping a <ul> of links, styled with flexbox for the horizontal desktop layout and a slide-in panel for mobile — nothing more exotic is required. The part that actually breaks in production isn't the CSS; it's that a mobile drawer hidden with the wrong property stays reachable by keyboard while invisible to the eye. This site's own header shipped that exact bug, and the fix is the useful part of this post.
The short answer
Build the bar from <header><nav aria-label="..."><ul><li><a>, lay it out with display: flex (or Tailwind's flex utilities) for the row, and toggle a mobile drawer with a transform and a boolean — not display: none, which this repo's own header case shows can trap keyboard focus on links a sighted mouse user can no longer see.
What a plain nav bar needs, and nothing more
Six ingredients, in this repo's own header (src/components/organisms/Header.tsx):
- A
<header>landmark, fixed to the top. <nav aria-label="Primary">wrapping the link list — onearia-labelper<nav>element on the page, since more than one unlabeled<nav>is ambiguous to a screen reader.- Links as a real
<ul><li><a>list, not<div>s — the list semantics announce "navigation, list, 4 items" for free. - A mobile toggle button with
aria-expandedreflecting open/closed state. - A CSS transform (
translate-x-full→translate-x-0) for the slide-in, not a display toggle, so the panel can animate. - Escape closes it, and a body-scroll lock stops the page scrolling behind an open drawer.
That's the whole specification. Everything past this — sticky-on-scroll, active-link underlines, a search box — is enhancement layered on top.
// src/components/organisms/Header.tsx — the link list
<nav aria-label="Primary">
<ul className="flex flex-col gap-5 xl:flex-row xl:items-center 2xl:gap-8">
<li>
<Link href="/templates">Templates</Link>
</li>
<li>
<Link href="/pricing">Pricing</Link>
</li>
<li>
<Link href="/docs">Docs</Link>
</li>
<li>
<Link href="/blog">Blog</Link>
</li>
</ul>
</nav>
Four links. This header used to carry a fifth item, a "Resources" dropdown — removed once an audit found its only non-duplicate entry was Blog, and the other two rows repeated links already in the bar two items over. A navigation bar with a dropdown that resolves to one unique destination is a dropdown for its own sake; flattening it to a plain link cut a whole layer of ARIA disclosure logic this component didn't need.
The bug: pointer-events-none hides the drawer from a mouse, not from Tab
The mobile drawer is a fixed-position panel that slides off-screen when closed. The first version closed it with pointer-events-none alone — which stops clicks, but does nothing to a keyboard user tabbing through the page. Result: on a phone-width viewport with the drawer closed, five links (Templates, Pricing, Docs, Blog, and the sign-in button) were still in the tab order, sitting off-screen, each one a silent, invisible stop for anyone navigating by keyboard.
// src/components/organisms/Header.tsx — the fix
className={`fixed inset-y-0 right-0 z-9999 flex w-[85%] max-w-xs flex-col
... transition-[transform,visibility] duration-300 ease-in-out
xl:pointer-events-auto xl:visible xl:static ...
${navOpen ? "visible translate-x-0" : "pointer-events-none invisible translate-x-full"}`}
invisible (CSS visibility: hidden) takes an element out of the tab order the way pointer-events-none never does — and pairing it with transition-[transform,visibility] means the panel only actually becomes invisible once the slide-out transition finishes, so the close animation still plays instead of vanishing instantly. xl:visible on the desktop breakpoint keeps the same markup interactive above the drawer's own breakpoint, so there's one component, not two, for both layouts.
The backdrop and the stacking order
A fixed-position mobile drawer needs a way to close on outside click, and this header adds a full-screen backdrop for exactly that:
<div
aria-hidden="true"
onClick={() => setNavOpen(false)}
className={`fixed inset-0 z-9998 bg-gray-900/50 transition-opacity duration-300 xl:hidden
${navOpen ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
aria-hidden="true" keeps it out of the accessibility tree — it's a purely visual dimming layer with a click handler, not content — and it uses the same pointer-events-none toggle that caused the drawer bug above, correctly this time, because a backdrop that's never meant to receive keyboard focus has nothing to trap.
The z-index stack across this header has four layers, in order: the backdrop at z-9998, the header bar and the drawer panel both at z-9999, and the hamburger toggle button itself at z-[10000] — one above everything else, because the same button has to stay clickable to close the menu while its own drawer is open at z-9999 on top of it. Get that ordering wrong — put the toggle at the same layer as the drawer instead of above it — and the open drawer can visually cover its own close button on narrow viewports.
Why xl and not md is the mobile/desktop breakpoint here
This header switches from the mobile drawer to the horizontal bar at Tailwind's xl breakpoint (1280px), not the more commonly reached-for md (768px). With four nav links, a GitHub icon, a saved-templates button, a Support link and a sign-in/dashboard button all needing to fit in one row, md-width viewports (tablets, and any laptop with a narrower content pane) don't have room for the full horizontal bar without wrapping or truncating labels. The breakpoint choice here is a direct function of how many items the bar actually holds — a header with two links could reasonably switch at md; this one, with roughly seven interactive elements once icons and buttons are counted, needs the extra width xl provides before going horizontal.
The comparison that matters
| Approach | Keyboard reachable when closed? | Animates? | Screen-reader list intact? |
|---|---|---|---|
display: none toggle | No — removed from the accessibility tree entirely | No — nothing to transition | Yes, but only while open |
pointer-events-none alone (this repo's original bug) | Yes — the exact defect | Yes | Yes, even off-screen |
visibility: hidden + transform (the fix) | No, once the transition completes | Yes | Yes, while open |
opacity: 0 alone | Yes — same defect as pointer-events-none | Yes | Yes, even invisible |
aria-hidden alone, no visibility change | No to assistive tech, yes to Tab (a different mismatch) | Yes | No |
The middle three rows are all "the panel disappears visually," and only one of them also leaves the tab order. opacity: 0 and pointer-events-none both look identical to a sighted mouse user testing the site — which is exactly why this bug survives a visual QA pass and only shows up under keyboard testing or an axe/Lighthouse a11y audit that checks focusable-but-hidden elements.
The desktop/mobile split, one component
xl: variants keep the fixed drawer and the horizontal desktop bar as the same underlying markup rather than two components with duplicated link lists:
className="... xl:static xl:inset-auto xl:z-auto xl:max-w-none xl:flex-1
xl:translate-x-0 xl:flex-row xl:items-center xl:justify-between
xl:overflow-visible xl:bg-transparent xl:transition-none"
Below xl, it's a fixed-position, transform-driven drawer. At xl and above, every one of those position/transform utilities is overridden back to a static, horizontal flex row — same four <li> elements, same aria-label, no second render path to keep in sync when a link is added or removed.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Mobile menu links focusable while visually hidden | Closed state uses pointer-events-none or opacity-0 only | Add visibility: hidden (Tailwind invisible) to the closed state |
| Nav labeled "navigation, list" but a screen reader can't tell which nav | Multiple <nav> elements with no aria-label | Give every <nav> on the page a distinct aria-label |
| Dropdown panel used for a menu with one unique link | A "Resources" or "More" menu duplicating links already in the bar | Flatten it — audit each dropdown for actual unique destinations |
| Slide-in drawer snaps shut instead of animating | visibility changes are applied without a matching transition property | List visibility in the transition property alongside transform |
| Background scrolls behind an open mobile drawer | No scroll lock on <body> while the drawer is open | Set document.body.style.overflow = "hidden" on open, restore on close |
| Escape key does nothing while the drawer is open | No keydown listener attached while open | Attach one in a useEffect scoped to the open state, remove it on close |
Frequently asked questions
Do I need a library to build an accessible navigation bar?
No — the pattern is <nav> + <ul> + <a>, a toggle button with aria-expanded, and CSS for layout and the mobile transform. This repo's header ships zero navigation-specific dependencies.
What's the difference between this and a mega menu?
A plain nav bar is a flat list of links, covered here. A mega menu adds a disclosure panel per item — covered separately in Building an Accessible Mega Menu, which goes into why that pattern should still use the navigation semantics rather than the ARIA menu pattern most tutorials reach for.
Should the mobile menu use display: none or visibility: hidden?
Use visibility: hidden (or an equivalent off-screen transform plus it) if you want the closed panel to animate on open and stay untabbable when closed. display: none is simpler and also untabbable, but it can't participate in a CSS transition.
Why did this header remove its "Resources" dropdown? An audit found it added disclosure logic for exactly one link (Blog) that wasn't already in the bar — the other entries duplicated Docs and the Support button. A dropdown that resolves to one unique destination is complexity with no payoff; flattening it removed a whole interaction pattern the header didn't need.
Templates in this post
ASoc Amplify, ASoc Atelier and ASoc Axiom are Next.js + Tailwind landing page templates built with the same semantic nav structure described above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
