HTML Nav: The Element, Not the Layout
A real production nav, from a 4-item desktop bar to a slide-out drawer — the aria-expanded toggle, the z-index stacking, and what a div-only navbar loses.
An HTML nav is a <nav> landmark wrapping a list of links — <nav aria-label="…"><ul><li><a></a></li></ul></nav> — not a styling choice. The element itself does the work: it tells a screen reader "this is a navigation region," so a user can jump straight to it instead of tabbing through everything above it. This storefront's own header is a real, shipping example of that markup at every breakpoint, from a four-item desktop bar down to a slide-out mobile drawer, and it's a useful reference for exactly the parts a raw <div>-based navbar gets wrong.
The element, not the layout
Flexbox or grid decides how a nav looks. The <nav> element decides what it is to a browser and assistive technology, and that distinction is the whole reason "how do I build a nav in HTML" isn't really a CSS question. This codebase's header wraps its primary links in exactly one:
// src/components/organisms/Header.tsx
<nav aria-label="Primary">
<ul className="flex flex-col gap-5 xl:flex-row xl:items-center 2xl:gap-8">
<li className="nav__menu group xl:py-7">
<Link className="font-medium text-text-color group-hover:text-primary" href="/templates">
Templates
</Link>
</li>
{/* Pricing, Docs, Blog follow the same shape */}
</ul>
</nav>
Four items — Templates, Pricing, Docs, Blog — each a real route, each a semantic <li> inside a semantic <ul> inside the <nav>. aria-label="Primary" matters specifically because this page has more than one navigation region: the footer has its own link columns, and a page with two unlabeled <nav> elements gives assistive tech two regions both announced as "navigation," indistinguishable from each other. One label per <nav> is what makes "jump to navigation" a meaningful shortcut instead of a coin flip.
What a <div>-based nav loses
Nothing stops you from building the same visual bar out of <div> and <span>, and it will look identical. What it won't do:
| Behavior | <nav> + <ul>/<li>/<a> | <div>/<span>-only |
|---|---|---|
| Announced as a navigation landmark | Yes, automatically | No — needs role="navigation" added by hand |
| "Skip to navigation" / landmark jump lists | Works out of the box in every major screen reader | Requires the same ARIA role, manually kept in sync |
| List semantics ("list of 4 items") | Announced automatically via <ul>/<li> | Lost unless role="list"/role="listitem" are added |
| Keyboard focus order and link semantics | Native <a> — focusable, Enter activates, shows in the browser's link list | A <div onClick> needs tabIndex, a role="link"/role="button", and a manual keydown handler for Enter/Space |
| SEO link discovery | Crawlers parse <a href> natively | A JS-driven <div> "link" may not be crawlable at all |
| Print / reader-mode stripping | Browsers and reader modes recognize <nav> as chrome, not content, and can omit it | Ambiguous — often kept as if it were body content |
Every row on the right side is recoverable with enough ARIA, but it's ARIA added to compensate for not using the element whose entire job is being a navigation region. The zero-ARIA version above is not a simplification — it's the actual number of attributes this codebase's real desktop nav needs beyond aria-label: none.
The mobile case: same <nav>, a client-side toggle around it
Where "how do I make an HTML nav" usually gets harder is the responsive collapse, because the toggle button, the open/close state, and the off-canvas panel are all things <nav> itself has no opinion about. Header.tsx is a Client Component specifically for this — everything above the xl breakpoint renders the same <nav> with no JavaScript involvement at all; below it, three pieces of state-driven behavior wrap around the identical markup:
// src/components/organisms/Header.tsx
const [navOpen, setNavOpen] = useState(false);
<button
aria-label={navOpen ? "Close menu" : "Open menu"}
aria-expanded={navOpen}
onClick={() => setNavOpen((o) => !o)}
type="button"
>
{/* five spans forming an animated hamburger-to-X */}
</button>
aria-expanded is the load-bearing attribute here — a screen reader announces "Open menu, button, collapsed" or "…, expanded," which is the mobile-nav equivalent of <details>'s built-in open state. aria-label swapping between "Open menu" and "Close menu" means the announced name always matches what the button will do next, not what it currently is.
Two more behaviors exist purely because the drawer covers the whole viewport once open, and neither is nav-specific — they're general modal-overlay hygiene applied to this one:
// src/components/organisms/Header.tsx — body scroll lock + Escape to close
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]);
Without the scroll lock, a visitor can drag the page behind the open drawer on a touch screen — the drawer looks modal but the body underneath still scrolls, which reads as a bug even though nothing is technically broken. The Escape handler and a full-screen aria-hidden overlay <div> that closes the drawer on click (not shown above) round out the three ways this codebase lets a visitor dismiss the drawer: the toggle button, the backdrop, and the keyboard — all three run through the same setNavOpen(false), so there's exactly one place that decides the drawer is closed.
Three layers, one z-index order
The drawer isn't the only thing that has to appear above the page — the toggle button has to stay clickable above the drawer it controls, and the backdrop has to sit between the two. This codebase pins all three explicitly rather than relying on DOM order to get it right:
// src/components/organisms/Header.tsx — the stacking, trimmed to the relevant classes
<div className="relative z-[10000] xl:hidden"> {/* the toggle button */}
<div className="fixed inset-0 z-9998 bg-gray-900/50" /> {/* the backdrop */}
<div className="fixed inset-y-0 right-0 z-9999"> {/* the drawer itself */}
The header element that contains all three is itself z-9999, matching the drawer — which is why the button gets bumped to 10000: without an explicit value one step higher, the button would render at the same stacking level as its own drawer and become unreachable once the drawer opens on top of it. This is the kind of bug that only shows up after the fact — everything looks fine until the drawer is open, at which point the "close" button silently stops receiving clicks because a later-painted sibling covers it.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Two <nav> elements with no aria-label | Screen reader announces "navigation" twice with no way to tell them apart | Label every <nav> — aria-label="Primary", "Footer", etc. |
Nav links built as <div onClick={...}> | Not keyboard-focusable, not in the browser's link list, invisible to most crawlers | Use <a href>; if it must be a client-side transition, keep the real href and intercept the click |
Hamburger button with no aria-expanded | Screen readers can't tell whether the menu is open | Toggle aria-expanded={navOpen} alongside the visual state |
| Mobile drawer open, page behind it still scrollable | Feels broken on touch devices — the "modal" isn't actually modal | Lock document.body.style.overflow while the drawer is open, and restore it on close |
| Drawer with no keyboard escape route | Keyboard users can open it but have no way back without hunting for the toggle | Add an Escape key handler that closes it |
Nav items as <li> with no <ul> wrapper, or <a> with no <li> | Assistive tech loses the "N items" list count | Keep the full <nav><ul><li><a> nesting even when styling flattens it visually |
Frequently asked questions
Do I need <ul>/<li> inside <nav>, or is a <nav> full of <a> tags enough?
<a> tags alone work visually and are still keyboard-accessible, but wrapping them in <ul>/<li> gives assistive tech a list count ("navigation, list of 4 items") that a flat run of links doesn't announce. This codebase keeps the list markup even though the visual layout — flex flex-row on desktop — has nothing list-like about it.
Should the logo link be inside the <nav> element?
This codebase's doesn't — the logo <Link> sits in the header markup alongside the <nav>, not inside it, because it isn't one of the navigation options; it's a persistent way home that happens to render in the same header. Either placement is defensible; what matters is that the <nav> you label "Primary" contains the actual choice of destinations.
Is a <header> element the same thing as a <nav>?
No — <header> is a landmark for introductory content (a logo, a site name, often a nav), while <nav> specifically marks navigation links. This codebase's <header> element wraps the logo, the <nav>, and the sign-in/support actions together; only the links list is inside the <nav>.
What HTML does a working responsive navbar actually need, minimum?
A <nav aria-label="…"> around a link list, a <button aria-expanded> to toggle it below the breakpoint where it collapses, and CSS to hide/show accordingly. Everything past that — the animated hamburger icon, the slide-in drawer, the scroll lock — is progressive polish on top of markup that's already correct without it.
Templates where this pattern already ships
ASoc Vox, ASoc Weave and ASoc Zenith each ship their own responsive header built on the same <nav>-plus-toggle shape — a fixed top bar on desktop, a slide-out drawer below it.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates. For the animation technique behind this codebase's other disclosure pattern, read Collapsible HTML: why this codebase never reaches for <details>; for the client-only session check that shares this same header component, Auth in React: the session belongs in a cookie.
