I keep seeing teams ship beautiful pages that fall apart the moment you shrink the viewport or try to use a keyboard. The weakest link is almost always the navigation bar. It looks fine on a big screen, but it becomes cramped, inaccessible, or inconsistent on phones and tablets. I’ve learned the hard way that the nav bar isn’t just decorative — it’s the site’s control panel. If it’s fragile, everything downstream feels fragile too.
Here’s the good news: you don’t need a framework to build a modern, responsive navigation bar with solid behavior. You need a semantic HTML base, a mobile‑first CSS layout, and a small slice of JavaScript for interactivity. I’ll walk you through a fully runnable example, explain why each part exists, and show the patterns I use in production. I’ll also cover accessibility details, edge cases, and when JavaScript is and isn’t the right choice. By the end, you’ll have a nav bar that behaves predictably across screen sizes and input types — and you’ll understand the reasoning behind every line.
The core idea: structure, style, behavior
A navigation bar is like the front desk of a hotel. It should greet people quickly, show the most important links, and never block the hallway. To get that right, I separate the work into three layers:
- Structure (HTML): semantic elements and a logical reading order
- Style (CSS): layout rules and responsive behavior
- Behavior (JavaScript): interactivity such as opening and closing a menu
If you blend these layers too early, you’ll fight your own code. I recommend starting with a clean HTML skeleton that reads well even without CSS. Then add CSS to handle layout changes at different widths. Finally, add the smallest JavaScript needed to switch states.
Below is the full example we’ll build and refine. It’s minimal, but I’ll extend it later with accessibility and real‑world patterns.
Responsive Navigation Bar
:root {
--nav-height: 64px;
--accent: #0f5132;
--border: #e4efe7;
--text: #1d1f1e;
--bg: #ffffff;
}
- {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--text);
background: var(--bg);
}
a {
color: inherit;
text-decoration: none;
font-weight: 600;
}
.site-header {
border-bottom: 2px solid var(--border);
}
.navbar {
height: var(--nav-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.brand img {
width: 44px;
height: 44px;
}
.menu-toggle {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.menu-toggle .bar {
display: block;
width: 26px;
height: 2px;
background: var(--text);
margin: 4px 0;
}
.nav-menu {
position: absolute;
top: var(--nav-height);
left: 0;
right: 0;
background: var(--bg);
border-bottom: 2px solid var(--border);
padding: 8px 0;
}
.nav-menu a {
display: block;
text-align: center;
padding: 12px 0;
}
.nav-menu a:hover,
.nav-menu a:focus-visible {
background: #f2f8f4;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (min-width: 700px) {
.menu-toggle {
display: none;
}
.nav-menu {
position: static;
display: flex;
gap: 24px;
padding: 0;
border-bottom: none;
}
.nav-menu a {
padding: 0;
}
}
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
// Keep state in one place to avoid mismatches between hidden and aria-expanded.
toggle.addEventListener(‘click‘, () => {
const isOpen = toggle.getAttribute(‘aria-expanded‘) === ‘true‘;
toggle.setAttribute(‘aria-expanded‘, String(!isOpen));
menu.hidden = isOpen;
});
HTML that reads well without CSS
I start with semantic elements because they make your nav usable even if styles fail. A header wraps the site identity and primary navigation. A nav element tells assistive tech, “These links are the main routes.” I also use a real button for the hamburger so it’s keyboard‑friendly by default.
Key structural points I care about:
- The brand link comes first, which is a logical place for the home action.
- The toggle button sits before the menu content in the DOM. This matches visual flow on mobile.
- The menu uses a container with links, not a list by default. A
ulis also valid; I pick based on whether I need list semantics for analytics or styling. - The toggle button includes
aria-controlsandaria-expanded, which I update in JavaScript.
Why I use hidden instead of a custom class: hidden removes the menu from the accessibility tree and prevents it from being focusable. It also keeps my CSS small. In a nav bar, hiding should be a true hide, not just “off screen.”
If you want list semantics, this variation is also solid and helps some screen readers announce counts:
And the CSS changes are trivial. I often switch to list semantics when I need finer control over spacing or when a design system expects lists for consistent styling.
Mobile‑first CSS that scales up cleanly
I build the layout for small screens first, then add a single breakpoint for larger screens. That keeps the CSS simple and prevents duplicated rules.
On mobile:
- The nav bar shows the brand and a toggle button.
- The menu drops down under the bar and fills the width.
- Links are stacked and centered for easy tapping.
On desktop:
- The toggle button disappears.
- The menu becomes an inline row.
- No absolute positioning is needed.
The CSS above already does that, but let me explain the two rules that make or break the experience.
1) position: absolute on the menu for mobile
This ensures the menu drops below the header without pushing content down. If you prefer the menu to push content, you can instead keep it static and remove the absolute rules. I like the overlay effect because it keeps the layout stable and feels smoother on mobile.
2) The hidden attribute
I avoid display: none toggles in CSS for this use case. With hidden, the menu is removed from layout and from the accessibility tree. You still can toggle it in JavaScript, and you avoid the common bug where invisible links remain focusable.
One more improvement I often add is a subtle elevation to separate the drop‑down from content:
.nav-menu {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
That’s optional, but it provides a clear visual layer on mobile.
JavaScript behavior: small, predictable, safe
The JavaScript should do exactly two things:
- Toggle visibility
- Keep ARIA in sync
If you do more, you risk having UI state that drifts from reality. I also avoid class toggles when a native attribute like hidden works. Here’s a slightly more defensive version that handles missing elements and uses event delegation for future extensions:
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
if (toggle && menu) {
toggle.addEventListener(‘click‘, () => {
const isOpen = toggle.getAttribute(‘aria-expanded‘) === ‘true‘;
toggle.setAttribute(‘aria-expanded‘, String(!isOpen));
menu.hidden = isOpen;
});
}
I also recommend closing the menu when someone clicks a link on mobile. This is a small detail, but it prevents a common frustration where the menu stays open after you go to a section on a single‑page layout:
menu.addEventListener(‘click‘, (event) => {
if (event.target instanceof HTMLAnchorElement) {
toggle.setAttribute(‘aria-expanded‘, ‘false‘);
menu.hidden = true;
}
});
That logic is not required for basic nav, but it’s a better experience for single‑page sites.
Animated toggle without brittle code
If you want a simple animation, I recommend animating the menu’s max height and opacity rather than a height of “auto.” The logic stays simple and avoids layout jank. Example:
.nav-menu {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 180ms ease, opacity 180ms ease;
}
.nav-menu[data-open="true"] {
max-height: 280px;
opacity: 1;
}
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
if (toggle && menu) {
toggle.addEventListener(‘click‘, () => {
const isOpen = menu.dataset.open === ‘true‘;
menu.dataset.open = String(!isOpen);
toggle.setAttribute(‘aria-expanded‘, String(!isOpen));
menu.hidden = false; // Keep it in the tree so transitions can run.
if (isOpen) {
// Wait for animation to finish before hiding.
setTimeout(() => { menu.hidden = true; }, 200);
}
});
}
In that version, I add a timed hide for a smooth close. I keep the timeout slightly longer than the transition to avoid cutting the animation. This is one of the few times I tolerate a timer because it matches a short, fixed duration.
Accessibility that actually holds up
I treat accessibility as behavior, not decoration. A nav bar is a high‑traffic component, so it must be predictable for keyboard and screen reader users.
Here are the minimum practices I follow:
- The toggle is a button, not a div or span.
- The toggle has a readable label using
sr-onlytext. aria-expandedreflects the actual state.- The menu is removed from the tab order when hidden.
Keyboard focus order
A keyboard user should tab to the menu toggle, press Enter or Space, then tab through links. When the menu is closed, those links should not be reachable. Using hidden makes that automatic.
Focus visibility
If you hide focus outlines in CSS, you’re creating a trap. I keep :focus-visible styles for links and the toggle. Here’s a friendly style I often use:
.nav-menu a:focus-visible,
.menu-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
Skip link for long pages
If a page is long, you can add a “Skip to content” link at the top. That’s outside the nav bar, but it dramatically improves usability for keyboard users. I usually place it right after body and style it to appear only on focus.
Skip to content
.skip-link {
position: absolute;
top: -40px;
left: 16px;
background: #fff;
color: #000;
padding: 8px 12px;
border: 2px solid #000;
}
.skip-link:focus {
top: 12px;
}
This is simple and effective, especially for content‑heavy pages.
Real‑world patterns I use in production
A nav bar is rarely just four links. Here are patterns I use often and how I handle them.
Sticky navigation
A sticky nav keeps the header visible as you scroll. It’s useful for long pages and docs. It can also be annoying if it eats vertical space. I use sticky when the page is long and the nav is small.
.site-header {
position: sticky;
top: 0;
background: var(--bg);
z-index: 10;
}
If you add shadows on scroll, don’t compute them on every tick. Use CSS box-shadow and toggle a class via IntersectionObserver if needed. That avoids heavy scroll handlers.
Active link indicator
People need to know where they are. For multi‑page sites, I add a data attribute to the current link on the server or at build time. Then I style it.
Services
.nav-menu a[aria-current="page"] {
color: var(--accent);
border-bottom: 2px solid var(--accent);
padding-bottom: 4px;
}
Overflow handling for many links
If your nav grows beyond five or six items, the mobile menu is fine but desktop can get cramped. I often do one of these:
- Move secondary links into a “More” menu.
- Move actions like “Sign in” to a button on the right.
- Use a horizontal scroll container on narrow desktop widths.
For a simple scroll container:
.nav-menu {
display: flex;
gap: 24px;
overflow-x: auto;
scrollbar-width: thin;
}
This lets the menu remain on one line without squishing text. It’s not my first choice, but it’s a safe fallback when designs get tight.
Mega menu?
If you need a mega menu, I usually reach for a small component library or build a dedicated panel with focus trapping. Mega menus are more like a mini app, and it’s too easy to ship a broken one without thorough testing. If you need this, plan time for keyboard management and closing behavior, or use a proven component.
Common mistakes and how I avoid them
I see the same pitfalls across teams. Here’s the short list I keep in mind.
- Hidden menus that still receive focus: Use
hiddenorinertrather than off‑screen positioning alone. - Click handlers on non‑buttons: Use a real button so keyboard access is free.
- CSS that depends on JavaScript classes only: Keep HTML and CSS functional without scripts.
- Overly complex animations: Use simple transitions and keep state in one place.
- Mobile menus that never close: Close on link click for single‑page layouts.
- Hardcoded heights: Use
max-heightand set a reasonable upper bound, or skip animation.
If you fix these, your nav bar will feel reliable on day one and stay reliable as the site grows.
When to use JavaScript and when to avoid it
I like JavaScript here, but I don’t use it blindly. Here’s how I decide.
Use JavaScript when:
- You need a collapsible menu on small screens.
- You need to toggle ARIA attributes for accessibility.
- You want to close the menu after a link click or on Escape.
Avoid JavaScript when:
- The menu is always visible and fits the layout.
- You can use CSS
:targetordetails/summaryfor a simple toggle. - The site must be resilient in environments where scripts are blocked.
In other words, JavaScript is a tool, not a requirement. I treat it as the last layer because that makes everything else sturdier.
Why JavaScript matters for navigation in 2026
Navigation has evolved. Users expect mobile menus to open and close smoothly, and they expect focus to behave predictably across devices. CSS can handle layout, but state changes — open, close, expanded, collapsed — are behavioral. That’s why JavaScript is still the best way to keep UI state and accessibility in sync.
A small script gives you:
- A single source of truth for open/closed state
- Consistent handling of mouse, keyboard, and touch
- Hooks for enhancements like closing on Escape or outside click
That last point is surprisingly important. If you don’t close a menu on Escape, you’re forcing keyboard users to tab all the way back to the toggle. It’s a tiny detail, but it’s a real quality marker.
A more complete JavaScript module
When I move from a demo to a production component, I add a few extras: escape to close, outside click to close, and a reduced‑motion branch for animations. This still fits in a handful of lines, but it feels much more polished.
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
if (toggle && menu) {
const closeMenu = () => {
toggle.setAttribute(‘aria-expanded‘, ‘false‘);
menu.hidden = true;
menu.dataset.open = ‘false‘;
};
const openMenu = () => {
toggle.setAttribute(‘aria-expanded‘, ‘true‘);
menu.hidden = false;
menu.dataset.open = ‘true‘;
};
toggle.addEventListener(‘click‘, () => {
const isOpen = toggle.getAttribute(‘aria-expanded‘) === ‘true‘;
if (isOpen) closeMenu(); else openMenu();
});
document.addEventListener(‘keydown‘, (event) => {
if (event.key === ‘Escape‘) closeMenu();
});
document.addEventListener(‘click‘, (event) => {
const target = event.target;
if (!menu.contains(target) && !toggle.contains(target)) {
closeMenu();
}
});
menu.addEventListener(‘click‘, (event) => {
if (event.target instanceof HTMLAnchorElement) closeMenu();
});
}
This version still keeps state in one place, but it feels resilient. The crucial detail is how I close on outside click: it checks whether the click is inside the menu or toggle, and closes only if not. That prevents weird loops and accidental closes.
If you’re worried about performance, don’t be. The event listeners are tiny, and the work they do is minimal. The real performance problems come from heavy scroll handlers, expensive DOM queries inside click handlers, or repeated layout thrashing. This script does none of those.
Progressive enhancement: the silent hero
Progressive enhancement means the page still works even if JavaScript fails. A nav bar is a perfect place to practice it.
Here’s the rule I follow: the menu should be visible by default in HTML, then hidden by JavaScript at runtime. This ensures that if scripts fail, users can still navigate. It’s a small adjustment with big benefits.
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
if (toggle && menu) {
menu.hidden = true; // hide only if JS is running
toggle.setAttribute(‘aria-expanded‘, ‘false‘);
toggle.addEventListener(‘click‘, () => {
const isOpen = toggle.getAttribute(‘aria-expanded‘) === ‘true‘;
toggle.setAttribute(‘aria-expanded‘, String(!isOpen));
menu.hidden = isOpen;
});
}
With this approach, no‑JS users see a simple menu right away. JS users get the collapsible behavior. This is a clean, user‑friendly compromise.
Edge cases that break nav bars (and how I handle them)
This is the part most tutorials skip. I’m not interested in demos that fail at the first real‑world constraint. Here are the edge cases I look for.
1) Very long link labels
If your labels are long — maybe for internationalization — the nav can wrap or overflow. I use two strategies depending on the design:
- Allow wrapping on desktop with a larger
line-heightand extra padding - Use
text-wrap: balanceortext-overflow: ellipsisfor very long labels
Example for truncation:
.nav-menu a {
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
I don’t love truncation, but it’s better than breaking layout. If the menu is vital, I’ll switch to a two‑row layout or move some links under “More.”
2) Fonts loading late
When web fonts load, text width changes. That can push nav items out of place. I often set a fallback font with similar metrics and reserve space with consistent padding. In critical layouts, I also test with the system font to see whether the layout still holds.
3) Scrollbar appearance
When a dropdown opens, it can add or remove a scrollbar and cause layout shift. On some platforms, this can bump the header a few pixels. If I need to prevent that, I lock body scroll while the menu is open.
const lockScroll = () => {
document.body.style.overflow = ‘hidden‘;
};
const unlockScroll = () => {
document.body.style.overflow = ‘‘;
};
Use this carefully. On mobile, locking scroll can cause weird bounce if not handled well, so I only enable it if the menu overlays content.
4) iOS click delay and tap targets
Old iOS versions had delays, but modern devices handle taps well. The bigger issue is tap target size. I make sure the menu toggle and links are at least 44px tall. In the CSS above, the toggle is exactly 44px and the links get padding, which is enough.
5) Screen reader announcements
If the menu opens and closes but screen readers don’t announce it, users get confused. That’s why aria-expanded is non‑negotiable. If you add an animation, make sure it doesn’t prevent the menu from becoming visible in the accessibility tree. Using hidden carefully is key.
6) Single‑page apps
In SPAs, the nav doesn’t reload. So you must close the menu on route change. If you’re not using a framework, you can still listen to history changes or just close the menu on link click. That’s usually enough for small SPAs.
7) Reduced motion preferences
Some users prefer reduced motion. If your menu animation is strong, respect prefers-reduced-motion.
@media (prefers-reduced-motion: reduce) {
.nav-menu {
transition: none;
}
}
That small change makes the UI feel respectful without extra logic.
Practical scenarios: how I choose a nav pattern
This is where the “best” approach becomes “the best approach for this page.” Here’s how I decide in real projects.
Scenario 1: Marketing page with 4–5 links
I keep the menu minimal, always visible on desktop, and collapsible on mobile. I avoid complex dropdowns because marketing pages are about clarity. The goal is simple: get to the CTA.
Scenario 2: Documentation with many sections
I use a sticky header and include a separate sidebar nav for deep links. The top nav stays small: logo, search, maybe a couple of top‑level items. The sidebar handles the heavy navigation.
Scenario 3: E‑commerce site
I often use a mega menu for categories and add a persistent cart icon. The JavaScript gets more complex, but I keep it modular. The main top bar stays simple, while the category panel handles deeper structure.
Scenario 4: Single‑page portfolio
I use anchor links and close the menu after click. I also highlight the active section based on scroll position, but only if it’s reliable. If I can’t get it right, I skip the highlight instead of shipping misleading behavior.
Scenario 5: Web app dashboard
I sometimes skip the top nav entirely and use a sidebar. If a top nav is required, it often contains account actions, notifications, and the logo, while the actual navigation is in the sidebar. This helps keep the top bar lean.
The key is that the nav bar isn’t universal. It should fit the information architecture of the site.
Comparative approach: CSS‑only vs JavaScript‑assisted
A common question is whether you can do this without JavaScript. You can, but there are tradeoffs. Here’s my quick comparison.
CSS‑only toggle
Using :target or details/summary can work for basic cases. It’s nice for low‑JS environments, but you lose reliable focus management and ARIA updates.
Pros:
- No JavaScript required
- Works even if scripts fail
Cons:
- Harder to close on outside click or Escape
- Limited state control
- Accessibility state not always reflected well
JavaScript‑assisted toggle
Small script, more control, better accessibility.
Pros:
- ARIA stays accurate
- Can close on Escape or outside click
- State is explicit and testable
Cons:
- Requires JS for the collapsible behavior
- Slightly more code to maintain
My default is JavaScript‑assisted because it’s more reliable for real users. But I still build it so the base HTML works on its own.
Performance considerations that actually matter
Navigation feels instant — or it feels heavy. The good news is that nav bars are small by nature, so performance problems are usually self‑inflicted. Here’s what I watch for.
Avoid expensive scroll listeners
It’s tempting to add fancy hide‑on‑scroll behavior, but it can lead to stutters. If you must, use requestAnimationFrame or IntersectionObserver rather than direct scroll events. For most sites, a simple sticky header is enough.
Keep DOM queries outside handlers
Don’t query DOM elements on every click. Store references once and reuse them. The examples above already do this, but it’s worth calling out.
Keep animations simple
Use opacity and transform rather than animating layout properties. If you animate height, you force layout recalculation. max-height is a compromise that works if your menu has a predictable size. If not, skip the animation or use a scale transform on a wrapper.
Don’t ship huge icon sets
If your nav uses icons, subset them or inline only the ones you need. A huge icon library adds weight for minimal benefit.
Use ranges instead of exact numbers
When I talk performance to teams, I keep it realistic. A nav bar script like this is usually in the tens of lines and adds negligible weight. You’re looking at a tiny fraction of your page size, not a dominant cost.
Practical testing checklist
I test nav bars manually because automated tests rarely catch UX problems. Here’s the checklist I run through before shipping:
- Resize from wide to narrow: does the menu transition gracefully?
- Keyboard test: can I open the menu and reach every link?
- Escape closes: does it return focus to a reasonable place?
- Click outside: does the menu close predictably?
- Reduced motion: does it respect the user setting?
- No‑JS: does the menu still show links?
- Screen reader: does it announce expanded/collapsed?
I don’t always run through all of these for a quick prototype, but I always do for production.
Alternative approach: details/summary
If you want a zero‑JS solution with decent accessibility, details/summary is the simplest native approach. It gives you a built‑in toggle state and works with keyboard by default.
.nav-details summary {
list-style: none;
}
.nav-details summary::-webkit-details-marker {
display: none;
}
This approach is nice for simple sites, but it’s harder to style and control in complex layouts. I use it when I need maximum simplicity and minimal JS.
Alternative approach: CSS :target
Another CSS‑only tactic is to use :target and anchor links to toggle state. I rarely use it because it changes the URL hash and can conflict with in‑page anchors, but it’s still a useful pattern to know.
Menu
.nav-menu { display: none; }
.nav-menu:target { display: block; }
This is fine for demos, but I don’t recommend it for production unless you have a very specific reason.
Expandable submenus without a framework
Once you have the basic menu, the next question is submenus. Here’s a simple pattern for a nested menu on mobile.
const submenuToggle = document.querySelector(‘.submenu-toggle‘);
const submenu = document.querySelector(‘.submenu‘);
if (submenuToggle && submenu) {
submenuToggle.addEventListener(‘click‘, () => {
const isOpen = submenuToggle.getAttribute(‘aria-expanded‘) === ‘true‘;
submenuToggle.setAttribute(‘aria-expanded‘, String(!isOpen));
submenu.hidden = isOpen;
});
}
The same principles apply: use real buttons, keep ARIA updated, and hide submenus from focus when collapsed. For desktop, I usually use hover + focus styles, but I still add keyboard support so users can open submenus with Tab.
Focus management for complex menus
If your nav opens an overlay or takes up most of the screen, you might need focus trapping. I don’t add a full trap for a simple dropdown, but if the menu is a full‑screen panel, I do. The goal is to prevent keyboard users from tabbing into the page behind the menu.
Here’s a lightweight approach using the inert attribute when supported:
const mainContent = document.querySelector(‘main‘);
const openMenu = () => {
menu.hidden = false;
if (mainContent) mainContent.inert = true;
};
const closeMenu = () => {
menu.hidden = true;
if (mainContent) mainContent.inert = false;
};
inert isn’t supported everywhere yet, so I treat it as a progressive enhancement. If you need full support, you can polyfill it or manage focus manually.
Styling details that elevate the component
Small details go a long way. Here are a few styling choices I use to make navs feel intentional without overdoing it.
Micro‑spacing and alignment
- Keep vertical rhythm by matching
line-heightto the nav height. - Align the brand and toggle using the same padding values so the bar feels balanced.
Contrast and focus
- Ensure text and focus outlines meet contrast guidelines.
- Keep hover and focus styles consistent — don’t make hover flashy and focus invisible.
Visual hierarchy
- Make the primary CTA a button‑style link on desktop.
- On mobile, keep all links consistent so touch targets are easy to scan.
Example: CTA button
.nav-menu .cta {
background: var(--accent);
color: #fff;
padding: 8px 14px;
border-radius: 999px;
}
.nav-menu .cta:hover,
.nav-menu .cta:focus-visible {
background: #0b3f27;
}
Get in touch
I use this sparingly, but it’s useful when you need one action to stand out.
The difference between “works” and “feels right”
A nav bar can technically work and still feel wrong. Here’s how I tell the difference:
- Does it open and close instantly without jitter?
- Are the touch targets comfortably large?
- Can a keyboard user reach every link and close the menu?
- Does the menu close when it should, not only when I remember?
- Does it look consistent on wide, narrow, and mid‑width screens?
If any of those answers are “no,” I keep iterating. Navigation is too important to ship a half‑polished version.
Production considerations I don’t skip
Even though a nav is “just UI,” it has production concerns.
Deployment consistency
If your site uses static builds, ensure the active link states are generated reliably. Don’t hardcode them in the HTML unless you are certain the build pipeline keeps them correct.
Monitoring and analytics
If you track nav clicks, do it in a way that doesn’t block navigation. Use passive analytics that don’t delay the link. If you need to track events, do it after navigation or with navigator.sendBeacon.
Scaling navigation content
As the product grows, navs tend to accumulate links. Plan for that. Decide early how you’ll handle secondary links, user actions, and overflow. If you ignore this, the nav becomes a cluttered junk drawer.
Comparison table: traditional vs modern approach
I like to summarize the differences in a quick table because it highlights tradeoffs clearly.
Traditional approach:
- Desktop‑first CSS
- JS toggles classes without ARIA
- Hidden links still focusable
- Layout breaks at mid‑width
- No plan for overflow
Modern approach:
- Mobile‑first CSS
- JS manages
aria-expandedandhidden - Hidden menu removed from accessibility tree
- Layout scales smoothly across widths
- Overflow handling built in
The modern approach is not more complicated — it’s just more intentional.
A reference implementation you can copy
Here’s a complete version that includes progressive enhancement, close‑on‑outside click, Escape to close, and reduced‑motion support. It stays small and readable.
.nav-menu {
position: absolute;
top: var(--nav-height);
left: 0;
right: 0;
background: var(--bg);
border-bottom: 2px solid var(--border);
padding: 8px 0;
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 180ms ease, opacity 180ms ease;
}
.nav-menu[data-open="true"] {
max-height: 280px;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.nav-menu {
transition: none;
}
}
@media (min-width: 700px) {
.nav-menu {
position: static;
display: flex;
gap: 24px;
padding: 0;
border-bottom: none;
max-height: none;
opacity: 1;
}
}
const toggle = document.querySelector(‘.menu-toggle‘);
const menu = document.querySelector(‘.nav-menu‘);
if (toggle && menu) {
// Progressive enhancement: keep menu visible without JS
menu.hidden = true;
menu.dataset.open = ‘false‘;
const openMenu = () => {
menu.hidden = false;
menu.dataset.open = ‘true‘;
toggle.setAttribute(‘aria-expanded‘, ‘true‘);
};
const closeMenu = () => {
menu.dataset.open = ‘false‘;
toggle.setAttribute(‘aria-expanded‘, ‘false‘);
// Allow close animation to finish before hiding
setTimeout(() => { menu.hidden = true; }, 200);
};
toggle.addEventListener(‘click‘, () => {
const isOpen = toggle.getAttribute(‘aria-expanded‘) === ‘true‘;
if (isOpen) closeMenu(); else openMenu();
});
document.addEventListener(‘keydown‘, (event) => {
if (event.key === ‘Escape‘) closeMenu();
});
document.addEventListener(‘click‘, (event) => {
const target = event.target;
if (!menu.contains(target) && !toggle.contains(target)) {
closeMenu();
}
});
menu.addEventListener(‘click‘, (event) => {
if (event.target instanceof HTMLAnchorElement) closeMenu();
});
}
This version is the one I use as a starting point. It’s resilient, accessible, and still easy to customize.
Why this approach scales
A good nav bar doesn’t just “work” — it scales with your product. The patterns here help with that:
- Single source of truth: ARIA and
hiddenstay aligned. - Mobile‑first layout: fewer media queries, easier maintenance.
- Progressive enhancement: no‑JS users aren’t blocked.
- Accessible defaults: real buttons and focus styles built in.
- Composable structure: easy to add links, CTA buttons, or submenus.
When teams complain that navigation is a mess, it’s usually because they skipped one of those foundations. If you build on this structure, you avoid most of the headaches.
Final thoughts
I’ve built nav bars that were over‑engineered and nav bars that were too minimal. The sweet spot is a simple, semantic foundation that scales with small, well‑targeted JavaScript. Don’t try to be clever. Be clear, consistent, and respectful of how people actually navigate.
If you take nothing else from this, remember these three rules:
1) Start with semantic HTML that works on its own.
2) Let CSS handle layout; let JavaScript handle state.
3) Keep accessibility in sync with visible behavior.
Do that, and your navigation bar will feel solid on every device, for every user, and for every future iteration.


