How to Make a Sticky Navigation Bar
A thorough walk-through of an important skill for any developer.
What is a navigation bar?
A navigation bar is the site’s spinal cord. It orients users, signals hierarchy,
and—when built correctly—silently boosts conversions. Semantics matter:
<header>
and <nav aria-label="Primary">
give assistive
tech the landmarks it needs; predictable link order and visible focus states give humans the
confidence to move. That’s UX, a11y, and SEO shaking hands.
A solid nav does four things: (1) it stays findable, (2) it doesn’t jiggle the layout, (3) it’s keyboard and screen-reader friendly, and (4) it scales when your sitemap triples. “Pretty” without those is just paint.
What makes it sticky?
“Sticky” means persistent visibility as you scroll. Done right, it improves task
completion and reduces pogo-sticking; done wrong, it trashes Core Web Vitals with layout
shifts (CLS) or scroll jank. Technically, you have three levers:
CSS position: sticky
(zero JS), an
Intersection Observer (toggle styles at a precise threshold), or a
scroll listener (brute force class toggles).
Golden rules: reserve vertical space up front, set scroll-padding-top
to your
header height so in-page anchors don’t hide under it, and keep hover/focus states visible
against both scrolled and unscrolled backgrounds.
The three best ways (ranked)
Below are three production patterns from “most boring (good)” to “it’ll work, but only if you must.” I’ll give you the full example in quotes, then the breakdown: steps, why it’s good, and where it bites.
Method 1 — CSS position: sticky
(Recommended)
Minimal JS, minimal surprises. If your header just needs to stay visible and maybe cast a shadow, this is the path. It respects user preferences, it’s GPU-friendly, and it won’t invent layout shifts.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sticky Nav with CSS</title>
<link rel="stylesheet" href="/assets/css/output.css" />
<style>
:root { --header-h: 72px; }
html { scroll-padding-top: var(--header-h); }
</style>
</head>
<body class="font-roboto text-base text-[#263f4e] bg-white">
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:text-black focus:px-3 focus:py-2 focus:rounded-md">Skip to main content</a>
<header class="sticky top-0 z-50 bg-gradient-to-b from-[#263f4e] via-[#324e61] to-[#587b91] text-white shadow-[0_6px_22px_rgba(0,0,0,0.12)]">
<div class="max-w-7xl mx-auto flex items-center justify-between px-4 py-3">
<a href="/" class="font-extrabold text-lg sm:text-xl hover:text-[#d4a856] transition-colors min-h-[44px] min-w-[44px]">Maelstrom Web Services</a>
<nav aria-label="Primary" class="flex gap-6">
<a href="/about/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">About</a>
<a href="/services/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Services</a>
<a href="/blog/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Blog</a>
</nav>
</div>
</header>
<main id="main" class="pt-[72px]">
<section class="max-w-4xl mx-auto px-4 py-10">
<h1 class="text-4xl font-extrabold text-[#263f4e]">Content Area</h1>
<p>Scroll to see the sticky nav in action.</p>
</section>
</main>
</body>
</html>
How it works (step-by-step)
- Reserve space: set
--header-h
and usept-[72px]
on<main>
so content doesn’t jump when the header sticks. - Stick it:
.sticky.top-0
pins the header when its scroll container’s top crosses. - Anchor sanity:
scroll-padding-top
equals header height, so in-page links aren’t hidden. - Focus: visible
focus-visible
rings keep keyboard users oriented.
Why it’s good
- Zero JS → fewer failure modes, less jank.
- No layout reflows beyond first paint.
- Great default for content sites and SMB marketing pages.
Where it’s bad
- Won’t fire “scrolled” thresholds for fancy transitions without help.
- Breaks if an ancestor has
overflow
ortransform
—classic gotcha.
Use when
- You want simple persistence + a shadow or color shift handled by CSS states.
Method 2 — Intersection Observer + class toggle (For styled thresholds)
When you need a “past hero = change header style” moment without hammering the main thread, use an Intersection Observer. Keep layout with CSS sticky, then toggle a class for color/ shadow/size changes. It’s precise, battery-friendly, and accessible.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sticky Nav with Intersection Observer</title>
<link rel="stylesheet" href="/assets/css/output.css" />
<style>
:root { --header-h: 72px; }
html { scroll-padding-top: var(--header-h); }
/* Style changes when scrolled past sentinel */
.is-scrolled {
background: white;
color: #17242d;
box-shadow: 0 6px 22px rgba(0,0,0,0.12);
}
.is-scrolled a { color: #17242d; }
.is-scrolled a:hover { color: #d4a856; }
</style>
</head>
<body class="font-roboto text-base text-[#263f4e] bg-white">
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:text-black focus:px-3 focus:py-2 focus:rounded-md">Skip to main content</a>
<!-- Sentinel sits above header -->
<div id="sentinel" style="height:1px" aria-hidden="true"></div>
<header id="site-header" class="sticky top-0 z-50 bg-gradient-to-b from-[#263f4e] via-[#324e61] to-[#587b91] text-white transition-colors duration-300">
<div class="max-w-7xl mx-auto flex items-center justify-between px-4 py-3">
<a href="/" class="font-extrabold text-lg sm:text-xl hover:text-[#d4a856] transition-colors min-h-[44px] min-w-[44px]">Maelstrom Web Services</a>
<nav aria-label="Primary" class="flex gap-6">
<a href="/about/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">About</a>
<a href="/services/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Services</a>
<a href="/blog/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Blog</a>
</nav>
</div>
</header>
<main id="main" class="pt-[72px]">
<section class="max-w-4xl mx-auto px-4 py-10">
<h1 class="text-4xl font-extrabold text-[#263f4e]">Content Area</h1>
<p>Scroll to see the style toggle without scroll jank.</p>
</section>
</main>
<script>
const header = document.getElementById('site-header');
const sentinel = document.getElementById('sentinel');
const io = new IntersectionObserver(([entry]) => {
header.classList.toggle('is-scrolled', !entry.isIntersecting);
}, { rootMargin: '-1px 0px 0px 0px' });
io.observe(sentinel);
</script>
</body>
</html>
How it works (step-by-step)
- Sentinel: a 1px element above the header marks your threshold.
- Sticky layout: header uses CSS sticky to persist; IO only changes style.
- Observer: when the sentinel leaves the viewport (
!entry.isIntersecting
), toggle.is-scrolled
. - Styling: swapped palette and shadow improve contrast on light backgrounds.
Why it’s good
- Precise threshold; no polling; main-thread friendly.
- Pairs cleanly with prefers-reduced-motion and theming.
Where it’s bad
- More moving parts than pure CSS.
- Old browsers need a small polyfill (rare these days).
Use when
- You want a classy “after hero” state change without scroll event churn.
Method 3 — Scroll listener + class toggle (Last resort)
The old standby. It works everywhere, but it can thrash the main thread if you’re sloppy.
If you must use it, keep it passive and throttle with requestAnimationFrame
.
Bonus points if the header is already sticky via CSS and JS only handles the style class.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sticky Nav with JS Scroll</title>
<link rel="stylesheet" href="/assets/css/output.css" />
<style>
:root { --header-h: 72px; }
html { scroll-padding-top: var(--header-h); }
.is-scrolled {
background: white;
color: #17242d;
box-shadow: 0 6px 22px rgba(0,0,0,0.12);
}
.is-scrolled a { color: #17242d; }
.is-scrolled a:hover { color: #d4a856; }
</style>
</head>
<body class="font-roboto text-base text-[#263f4e] bg-white">
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:text-black focus:px-3 focus:py-2 focus:rounded-md">Skip to main content</a>
<header id="header" class="sticky top-0 z-50 bg-gradient-to-b from-[#263f4e] via-[#324e61] to-[#587b91] text-white transition-colors duration-300">
<div class="max-w-7xl mx-auto flex items-center justify-between px-4 py-3">
<a href="/" class="font-extrabold text-lg sm:text-xl hover:text-[#d4a856] transition-colors min-h-[44px] min-w-[44px]">Maelstrom Web Services</a>
<nav aria-label="Primary" class="flex gap-6">
<a href="/about/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">About</a>
<a href="/services/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Services</a>
<a href="/blog/" class="py-1.5 px-3 text-white/90 hover:text-[#d4a856] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#d4a856] min-h-[44px]">Blog</a>
</nav>
</div>
</header>
<main id="main" class="pt-[72px]">
<section class="max-w-4xl mx-auto px-4 py-10">
<h1 class="text-4xl font-extrabold text-[#263f4e]">Content Area</h1>
<p>Scroll to see the nav change dynamically.</p>
</section>
</main>
<script>
const header = document.getElementById('header');
let ticking = false;
function onScroll() {
const scrolled = window.scrollY > 50;
header.classList.toggle('is-scrolled', scrolled);
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(onScroll);
ticking = true;
}
}, { passive: true });
</script>
</body>
</html>
How it works (step-by-step)
- Sticky for layout: header is sticky in CSS; JS only toggles styles.
- Threshold:
scrollY > 50
becomes the “after hero” moment. - Throttle: wrap updates in
requestAnimationFrame
to avoid flooding. - Passive listener: hints to the browser that you won’t cancel the event → smoother scroll.
Why it’s good
- Works everywhere; zero polyfills.
- Easy to reason about, easy to tweak.
Where it’s bad
- Easy to introduce jank or leak listeners.
- Threshold math goes stale when layout changes (A/B tests, banners, etc.).
Use when
- You’re legacy-heavy or need a fast fix with careful throttling.
Common pitfalls (and fast fixes)
- Sticky doesn’t stick? Check ancestors for
overflow
,transform
, orfilter
. Remove or move the header out of that context. - Anchors sit under the header? Set
html { scroll-padding-top: var(--header-h); }
and keep--header-h
in one place. - CLS spikes on load? Reserve header height with padding or a fixed min-height. Don’t inject the header late.
- Focus is invisible on light header? Provide distinct focus styles for both states; test with keyboard only.
- Mobile Safari quirkiness? Add
backdrop-filter
carefully; verify overscroll behavior; prefer simpler shadows to avoid repaints.
Ship checklist
- Semantic landmarks:
<header>
+<nav aria-label="Primary">
. - Keyboard-first test: Tab through links; verify visible focus in both states.
- Vitals: CLS ≤ 0.05; no long tasks during scroll.
- Anchors:
scroll-padding-top
matches header height. - Dark/light contrast: Meets WCAG AA in both header states.
- No global listeners lingering on route changes (if SPA/ISR).
Bottom line
Build the boring thing that never breaks. Start with pure CSS sticky. When you need a tasteful threshold shift, add an Intersection Observer. Reach for scroll listeners only when the first two won’t cut it—and tame them. That’s how you keep speed, accessibility, and polish while your content footprint scales.