ARTICLE AD BOX
I’m building a frontend application using Next.js and Tailwind CSS where each section takes up the full viewport height (100vh). I want to implement a scroll-to-next-section behavior (similar to scroll snapping), where each scroll moves exactly one section up or down.
Additionally, each section uses md:sticky top-0 to create a stacking animation effect.
Current Implementation
I’m handling the scroll manually using a wheel event:
useEffect(() => { if (window.innerWidth < 1024) return; let isAnimating = false; let accumulatedDelta = 0; const SCROLL_THRESHOLD = 80; const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (isAnimating) return; accumulatedDelta += e.deltaY; if (Math.abs(accumulatedDelta) < SCROLL_THRESHOLD) return; const direction = accumulatedDelta > 0 ? 1 : -1; accumulatedDelta = 0; isAnimating = true; window.scrollBy({ top: direction * window.innerHeight, behavior: "smooth", }); const unlock = () => { isAnimating = false; }; if ("onscrollend" in window) { const onEnd = () => { unlock(); window.removeEventListener("scrollend", onEnd); }; window.addEventListener("scrollend", onEnd); } else { setTimeout(unlock, 600); } }; window.addEventListener("wheel", handleWheel, { passive: false }); return () => { window.removeEventListener("wheel", handleWheel); }; }, []);Problem
This works most of the time, but I’m facing a few issues:
Sometimes scrolling skips multiple sections instead of just one
Occasionally the scroll gets “stuck” between sections
The behavior is inconsistent across devices (especially trackpads vs mouse wheel)
The sticky sections seem to interfere with scroll positioning
What I’m Trying to Achieve
Smooth scrolling between sections (1 scroll → 1 section)
No skipping or jitter
Works reliably across devices
Compatible with position: sticky stacking layout
What I’ve Tried
Accumulating deltaY with a threshold
Locking scroll with isAnimating
Using scrollend (with fallback timeout)
Already tried css scrollsnap but didn't get the expected results
Question
What is the correct/reliable way to implement this kind of section-by-section scrolling?
Should I avoid wheel handling entirely and use CSS scroll snapping instead?
Is there a better way to handle scroll locking and animation completion?
Could position: sticky be causing layout/scroll inconsistencies here?
Additional Context
Desktop-only behavior (disabled for < 1024px)
Each section is 100vh
Using Tailwind utility classes (md:sticky top-0)
Component
export function HomePageClient() { const [coords, setCoords] = useState({ homeY: 0, aboutY: 0, contactY: 0, }); // ✅ Scroll helper (clean & reliable) const scrollToSection = (id: string) => { const el = document.getElementById(id); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "start", }); }; // ✅ Measure positions (only for Navbar if needed) const measureCoords = () => { const getY = (id: string) => { const el = document.getElementById(id); if (!el) return 0; return window.scrollY + el.getBoundingClientRect().top; }; setCoords({ homeY: getY("hero"), aboutY: getY("about"), contactY: getY("contact"), }); }; // ✅ Initial load + resize useEffect(() => { const handler = () => measureCoords(); window.addEventListener("load", handler); window.addEventListener("resize", handler); return () => { window.removeEventListener("load", handler); window.removeEventListener("resize", handler); }; }, []); // ✅ Handle URL hash (#about etc.) useEffect(() => { const hash = window.location.hash.replace("#", ""); if (!hash) return; setTimeout(() => { scrollToSection(hash); }, 150); }, []); useEffect(() => { if (window.innerWidth < 1024) return; let isAnimating = false; let accumulatedDelta = 0; const SCROLL_THRESHOLD = 80; // tweak if needed const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (isAnimating) return; accumulatedDelta += e.deltaY; // only trigger when enough scroll intent is detected if (Math.abs(accumulatedDelta) < SCROLL_THRESHOLD) return; const direction = accumulatedDelta > 0 ? 1 : -1; accumulatedDelta = 0; isAnimating = true; window.scrollBy({ top: direction * window.innerHeight, behavior: "smooth", }); // 🔥 smarter unlock (not too fast, not too slow) const unlock = () => { isAnimating = false; }; // Prefer real scroll end if supported if ("onscrollend" in window) { const onEnd = () => { unlock(); window.removeEventListener("scrollend", onEnd); }; window.addEventListener("scrollend", onEnd); } else { // fallback setTimeout(unlock, 600); } }; window.addEventListener("wheel", handleWheel, { passive: false }); return () => { window.removeEventListener("wheel", handleWheel); }; }, []); return ( <div className="md:h-screen"> <Preloader onEnter={measureCoords} /> <div className="relative antialiased"> <Navbar YCordinates={coords} /> <main className="relative"> <Hero /> <About /> <Contact /> </main> <Footer /> </div> </div> ); }Sample section
<section id="hero" className="relative md:sticky top-0 md:h-screen min-h-[100dvh] lg:min-h-screen overflow-hidden max-w-[88rem] mx-auto flex flex-col z-2" style={{ backgroundColor: "var(--bg-depth-1)" }} aria-labelledby="hero-heading" > --- Content --- </section>If anyone has implemented something similar (especially with sticky stacking layouts), I’d appreciate guidance on the correct approach.
