How to Make a Sticky Navigation Bar

By · Updated

A production-grade walkthrough for sticky headers that balance User Experience (UX), accessibility, Search Engine Optimization (SEO), and Core Web Vitals without jank or design debt. Copy-pasteable patterns, clear guardrails, and a QA checklist you can ship with are included for convenience.

What is a navigation bar?

The navigation bar is a site’s attempt at creating a compass for new viewers to reference to keep from getting lost on their expedition. Professional navigation bars communicate hierarchy, trust, and direction in an easily digestible format. Like most user-facing technical implementations, when it’s engineered well, users glide; when it’s brittle, they bounce.

Semantics matter: using <header> and <nav aria-label="Primary"> provides assistive technology clear landmarks to orient around, while a predictable link order and visible :focus-visible states let keyboard users move confidently. Meaningful code also supports crawlability and internal linking, which strengthens topical coverage and helps search engines understand your site.

If you’re building content depth, read internal linking best practices and your strategy on how to build topical authority fast.

A solid nav does four things:

  • Stays findable: Never makes users hunt mid-task.
  • Doesn’t move the layout: No unexpected shifts or reflows.
  • Works for everyone: Screen readers, keyboards, and high-contrast users included.
  • Scales cleanly: From a 5-link MVP to a 500-page sitemap without rewrites.

Any site developer or manager understands well that “pretty” without those basic qualities is just paint on trash. Sticky navigation is categorically craftsmanship because it is a part of the many thoughtful defaults that quietly remove friction from users.

What makes it sticky?

Sticky means persistent visibility as you scroll, though many developers and designers confuse that to mean it is "fixed." The difference is obvious once explained: a fixed header is always pinned, while a sticky header becomes affixed when a scroll threshold is reached. The end goal of any navigation bar, but especially a sticky one, is to increase task completion and reduce bounce (internal or external). While this is a basic skill for any front-end developer, it is easy to mess up — which harms important performance metrics and user experience aspects like CLS, scroll anchors, and general jank.

You have three reliable approaches:

  1. Pure CSSposition: sticky is the best default.
  2. Intersection Observer — precise style thresholds via the W3C spec without main-thread churn.
  3. Scroll listener — last resort; throttle and use passive: true (Chrome guidance).

Universal rules to avoid pain:

  • Reserve space up front so layout never jumps.
  • Use scroll-padding-top so in-page anchors aren’t hidden under the header.
  • Design focus/hover states that remain visible on both light and dark header states.

The three best ways (ranked)

Below I supply three production patterns — from “boring and reliable” to “works, but be cautious.”

Each includes a full, copy-pasteable example and a breakdown of why and when to use it.

Method 1 — CSS position: sticky (Recommended)

This approach implements minimal JavaScript and culls surprises from the equation. If your header just needs to stay visible and maybe use a simple effect like shadow-casting, this is the place to start here.

It’s resilient, composable, and friendly to the rendering pipeline.

Example (copy-paste ready):
<!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)

  1. Reserve space: define --header-h and pad the main content so nothing jumps.
  2. Stick it: .sticky.top-0 pins the header when its container hits the top.
  3. Anchor safety: scroll-padding-top prevents hidden in-page anchors.
  4. Focus visibility: clear focus-visible rings keep keyboard users oriented.
Why it’s good
  • Zero JS → fewer failure modes, less jank.
  • No extra reflow beyond first paint.
  • Great default for content sites and SMB marketing pages.
Where it’s bad
  • Doesn’t emit threshold events for complex transitions.
  • Breaks if an ancestor has overflow or transform.
Use when
  • You want simple persistence plus minor cosmetic changes.

Method 2 — Intersection Observer + class toggle (For styled thresholds)

When you want a classy “after hero” style change without heavy scroll listeners, a safe bet is to pair CSS sticky (for layout) with an Intersection Observer (for state). I use this combination because it’s precise, battery-friendly, and accessible — see the spec for thresholds and root margins.

Example (copy-paste ready):
<!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); }
    .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>

  <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)

  1. Sentinel: a 1px element above the header marks your threshold.
  2. Sticky layout: CSS sticky handles persistence; the observer only flips styles.
  3. Observer: when the sentinel exits the viewport, toggle .is-scrolled.
  4. Styling: a light header with shadow after scroll improves contrast on bright content.
Why it’s good
  • Precise threshold; no polling; main-thread friendly.
  • Pairs cleanly with theme switches and reduced-motion.
Where it’s bad
  • More moving parts than pure CSS.
  • Older browsers may need a tiny polyfill.
Use when
  • You want an “after hero” style change without scroll event churn.

Method 3 — Scroll listener + class toggle (Last resort)

Old Reliable — it works everywhere...but it can also thrash the main thread if you’re sloppy. If you insist on this technique, keep it passive and throttle with requestAnimationFrame. See Chrome’s note on using passive event listeners for scroll responsiveness. Ideally the header remains sticky via CSS and JS only toggles a style class.

Example (copy-paste ready):
<!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)

  1. Sticky for layout: keep layout with CSS; JS only flips styles.
  2. Threshold: use a small offset (e.g., scrollY > 50) to avoid flicker.
  3. Throttle: group updates in requestAnimationFrame to reduce main-thread work.
  4. Passive: mark the listener { passive: true } to keep scrolling smooth.
Why it’s good
  • Works everywhere without polyfills.
  • Straightforward logic; easy to tweak.
Where it’s bad
  • Easy to introduce jank or leaks if you forget cleanup in SPAs.
  • Thresholds can go stale if layout changes (banners, experiments).
Use when
  • Legacy constraints or quick fixes—throttled and scoped carefully.

Design decisions that prevent jank

Sticky headers fail most often where design and layout intersect.

Lock these in:

  • Known height: define a consistent header height with a variable and reserve it on <main>.
  • Anchor safety: set html { scroll-padding-top: var(--header-h) } so in-page links never hide.
  • Z-index sanity: scope the header’s stacking context (z-50) and avoid random z-index on children.
  • Readable states: verify contrast for both pre-scroll (dark) and post-scroll (light) variants; keep focus rings visible.
  • Motion sensitivity: transition only what’s cheap (opacity, color, shadow). Respect @media (prefers-reduced-motion). For above-the-fold stability, see lazy loading vs eager loading.

For broader performance work, pair this with how to improve site speed.

Common pitfalls (and fast fixes)

  • Sticky doesn’t stick? An ancestor probably has overflow, transform, or filter. Remove it or move the header out of that context.
  • Anchors sit under the header? Add scroll-padding-top at the root level to match header height.
  • CLS spikes on load? Reserve header height before paint; don’t inject headers after hydration.
  • Focus invisible on light state? Provide explicit focus-visible rings that meet AA in both themes.
  • Safari quirks? Keep backdrop-filter subtle; test overscroll; avoid mixing fixed and sticky on iOS unless necessary. Reference: position: sticky lands in WebKit.

QA checklist before you ship

  • Semantic landmarks: <header> + <nav aria-label="Primary">.
  • Keyboard path: Tab forward/back through links; focus is always visible.
  • Vitals: CLS ≤ 0.05; no long tasks during scroll (profile with DevTools Performance).
  • Anchors: In-page links land below the header, not under it.
  • States: Contrast passes AA in dark and light header variants.
  • Cleanup: In SPAs/ISR, listeners are removed on route change.

Growing from 5 to 500 pages (without rewrites)

Sticky headers should survive growth. Your entire site should survive growth. For static sites, this looks like keeping navigation data-driven (YAML/JSON), generating menus from a single source of truth, and avoiding hard-coding thresholds. If the header height changes with A/B tests or banners, you won’t be refactoring magic numbers in JS — the CSS variable still controls layout and anchor safety.

As your topical map deepens, you use the header sparingly. The header’s job is wayfinding; deep discovery belongs to on-page sections, breadcrumbs, and contextual links. For shaping that lattice, see building topical authority fast and meta tags that actually convert.

References (deep dives)

Bottom line

Build the boring thing that never breaks. Start with pure CSS sticky—it’s resilient, fast, and accessible. If 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 with requestAnimationFrame and passive listeners. Reserve space early, protect anchors with scroll-padding-top, and keep states readable. Do this, and your sticky header disappears into the experience—exactly where it belongs.

Spot an error or a better angle? Tell me and I’ll update the piece. I’ll credit you by name—or keep it anonymous if you prefer. Accuracy > ego.

Portrait of Mason Goulding

Mason Goulding · Founder, Maelstrom Web Services

Builder of fast, hand-coded static sites with SEO baked in. Stack: Eleventy · Vanilla JS · Netlify · Figma

With 10 years of writing expertise and currently pursuing advanced studies in computer science and mathematics, Mason blends human behavior insights with technical execution. His Master’s research at CSU–Sacramento examined how COVID-19 shaped social interactions in academic spaces — see his thesis on Relational Interactions in Digital Spaces During the COVID-19 Pandemic . He applies his unique background and skills to create successful builds for California SMBs.

Every build follows Google’s E-E-A-T standards: scalable, accessible, and future-proof.