Smooth Scroll Navigation for One-Page Sites
Enhance user experience with smooth scrolling navigation for one-page websites. Ideal for portfolios and landing pages.
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 });
})();