How to Build a Lightweight CSS Spy Component From Scratch A “Scroll Spy” component automatically updates navigation links based on the user’s current scroll position on the page. While many developers reach for heavy JavaScript libraries to achieve this, you can build a high-performance, lightweight version using modern web standards.
Here is how to create a lightweight Scroll Spy component from scratch using vanilla JavaScript and CSS. The Strategy
Traditional scroll spies rely on window.addEventListener(‘scroll’, … ). This approach triggers hundreds of times per second, causing layout thrashing and stuttering performance.
Instead, we will use the Intersection Observer API. This built-in browser API detects when an element enters or leaves the viewport. It is asynchronous, highly optimized, and runs on the browser’s main thread wrapper safely. 1. The HTML Structure
We need a navigation menu and matching content sections. The href attributes of the navigation links must exactly match the id attributes of the sections.
Section One
Section Two
Section Three
Section Four
Use code with caution. 2. The Functional CSS
We use CSS to make the navigation sticky and style the active state. The rest of the styling handles section spacing so you can scroll freely. Use code with caution. 3. The JavaScript JavaScript Observer
This script initializes the IntersectionObserver. It watches all sections, identifies which one occupies the target area of the screen, and toggles the .active class on the corresponding link. javascript
document.addEventListener(“DOMContentLoaded”, () => { const links = document.querySelectorAll(“.nav-link”); const sections = document.querySelectorAll(“.spy-section”); // Observer Options const options = { root: null, // Defaults to the browser viewport rootMargin: “-20% 0px -60% 0px”, // Triggers when section hits the upper-middle screen threshold: 0, }; // Callback function when intersection happens const observerCallback = (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const // Remove active class from all links links.forEach((link) => link.classList.remove(“active”)); // Add active class to matching link const activeLink = document.querySelector( Use code with caution. Key Configuration: Tuning .nav-link[href="#${id}"]); if (activeLink) { activeLink.classList.add(“active”); } } }); }; // Initialize and observe const observer = new IntersectionObserver(observerCallback, options); sections.forEach((section) => observer.observe(section)); }); rootMargin
The magic of this lightweight script lives inside rootMargin: “-20% 0px -60% 0px”.
Think of rootMargin as a bounding box inside your screen that triggers the active state:
-20% (Top): Prevents the link from changing too early before the section clears the sticky navigation header.
-60% (Bottom): Shrinks the detection zone from the bottom, ensuring that only the section currently dominating the top/middle viewport registers as active. Conclusion
By avoiding massive third-party dependencies, you keep your bundle size small and your page speed optimal. This vanilla component requires under 30 lines of JavaScript, offers buttery-smooth 60fps performance, and provides an instantly modern user experience. If you’d like to expand this component, let me know:
Leave a Reply