JavaScript 74 views

Sticky Header Navigation with Smooth Scroll

Implement a sticky header navigation with smooth scrolling to anchors, enhancing user experience on long pages.

By TWC Team • Jan 30, 2026

Code

<!--
  Sticky Header Navigation with Smooth Scroll
  Smoothly scrolls to in-page anchors while accounting for a sticky header offset, with accessibility and reduced-motion support.
-->

<header id="header" class="sticky">
  <nav aria-label="Page sections">
    <ul>
      <li><a href="#section1">Section 1</a></li>
      <li><a href="#section2">Section 2</a></li>
      <li><a href="#section3">Section 3</a></li>
    </ul>
  </nav>
</header>

<style>
  .sticky {
    position: sticky;
    top: 0;
    background: white;
    padding: 10px 0;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    z-index: 1000;
  }
  nav ul {
    list-style: none;
    display: flex;
    justify-content: space-around;
    gap: 12px;
    margin: 0;
    padding: 0 12px;
  }
  nav a {
    text-decoration: none;
    color: black;
    padding: 8px 10px;
    border-radius: 8px;
  }
  nav a:focus-visible {
    outline: 2px solid #111;
    outline-offset: 2px;
  }
</style>

<script>
/**
 * Initializes smooth scrolling for in-page nav links.
 * Uses event delegation for performance and avoids polluting global scope.
 */
(() => {
  const header = document.getElementById('header');
  const nav = header?.querySelector('nav');

  if (!header || !nav) return;

  const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;

  const getHeaderOffset = () => {
    // Read layout once per click; avoids continuous reflows.
    const height = header.getBoundingClientRect().height;
    return Number.isFinite(height) ? Math.ceil(height) : 0;
  };

  const isSamePageHashLink = (anchor) => {
    try {
      const url = new URL(anchor.href, window.location.href);
      return url.origin === location.origin && url.pathname === location.pathname && !!url.hash;
    } catch {
      return false;
    }
  };

  const scrollToTarget = async (target) => {
    const offset = getHeaderOffset();
    const top = Math.max(0, target.getBoundingClientRect().top + window.scrollY - offset);

    // Async boundary keeps this extensible (e.g., analytics) and safe for future awaits.
    await Promise.resolve();

    window.scrollTo({ top, behavior: prefersReducedMotion ? 'auto' : 'smooth' });

    // Keep history in sync without triggering another jump.
    if (target.id) history.pushState(null, '', `#${CSS?.escape ? CSS.escape(target.id) : target.id}`);
    target.setAttribute('tabindex', '-1'); // Ensure focusable for screen readers
    target.focus({ preventScroll: true });
  };

  nav.addEventListener('click', async (event) => {
    const link = event.target.closest?.('a[href^="#"]');
    if (!link || !nav.contains(link) || !isSamePageHashLink(link)) return;

    event.preventDefault();

    try {
      const { hash } = new URL(link.href, window.location.href);
      const id = decodeURIComponent(hash.slice(1));
      const safeId = CSS?.escape ? CSS.escape(id) : id;
      const target = document.getElementById(id) || document.querySelector(`[id="${safeId}"]`);

      if (!target) return; // Target missing: fail silently (or optionally log)
      await scrollToTarget(target);
    } catch (err) {
      console.error('Smooth scroll navigation error:', err);
    }
  });

  // Example usage: Add <section id="section1">...</section> blocks below the header.
})();
</script>
Back to Snippets