Smooth scroll snapping between full-screen sections in Next.js + Tailwind sometimes skips sections or gets stuck

20 hours ago 1
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.

Read Entire Article