JavaScript 75 views

Smooth Scroll Navigation for One-Page Sites

Enhance user experience with smooth scrolling navigation for one-page websites. Ideal for portfolios and landing pages.

By TWC Team • Jan 28, 2026

Code

/**
 * Smooth Scroll Navigation for One-Page Sites
 * Attach to anchor links (e.g., <a class="nav-link" href="#section">) to scroll smoothly to sections.
 */

(() => {
  'use strict';

  const SELECTORS = { link: '.nav-link' };
  const DEFAULTS = { behavior: 'smooth', block: 'start', offsetTop: 0 };

  // Small async utility to ensure layout is ready before scrolling (handles fonts/images shifting layout)
  const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve()));

  const getTargetElement = (href) => {
    if (!href || !href.startsWith('#') || href === '#') return null;
    try {
      // CSS.escape prevents querySelector errors for IDs with special characters.
      const id = CSS?.escape ? CSS.escape(href.slice(1)) : href.slice(1);
      return document.getElementById(id) || document.querySelector(href);
    } catch {
      return null;
    }
  };

  const scrollToTarget = async ({ targetEl, offsetTop, behavior, block }) => {
    if (!targetEl) return;

    // Wait one frame to reduce the chance of scrolling to a stale position on busy layouts.
    await nextFrame();

    // Prefer native scrollIntoView; use manual offset if requested.
    if (offsetTop === 0) {
      targetEl.scrollIntoView({ behavior, block });
      return;
    }

    const top = Math.max(0, targetEl.getBoundingClientRect().top + window.pageYOffset - offsetTop);
    window.scrollTo({ top, behavior });
  };

  const createSmoothScrollHandler = (options = {}) => {
    const config = { ...DEFAULTS, ...options };

    return async (event) => {
      const link = event.currentTarget;
      if (!(link instanceof HTMLAnchorElement)) return;

      const href = link.getAttribute('href');
      const targetEl = getTargetElement(href);

      // If the target doesn't exist, fall back to default browser behavior.
      if (!targetEl) return;

      event.preventDefault();

      try {
        await scrollToTarget({ targetEl, ...config });

        // Keep URL in sync without adding to browser history.
        history.replaceState(null, '', href);

        // Accessibility: move focus to the section so keyboard users are not "lost".
        const needsTabIndex = !targetEl.hasAttribute('tabindex');
        if (needsTabIndex) targetEl.setAttribute('tabindex', '-1');
        targetEl.focus({ preventScroll: true });
        if (needsTabIndex) targetEl.removeAttribute('tabindex');
      } catch (error) {
        console.error('[SmoothScroll] Failed to scroll:', error);
      }
    };
  };

  const initSmoothScrollNav = (options) => {
    const navLinks = document.querySelectorAll(SELECTORS.link);
    if (!navLinks.length) return;

    const handler = createSmoothScrollHandler(options);

    // Performance: passive must be false because we call preventDefault().
    navLinks.forEach((link) => link.addEventListener('click', handler, { passive: false }));
  };

  // Auto-init on DOM ready to avoid global scope pollution.
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => initSmoothScrollNav({ offsetTop: 0 }));
  } else {
    initSmoothScrollNav({ offsetTop: 0 });
  }

  // Example usage: initSmoothScrollNav({ offsetTop: document.querySelector('header')?.offsetHeight ?? 0 });
})();
Back to Snippets