The Setup
Wedding invitation site. Beautiful full-page sections. Two requirements:
- Smooth parallax scrolling (Lenis)
- Section-by-section navigation (CSS scroll-snap)
Both seemed simple. Neither worked.
The Symptom
Scrolling felt janky. Sometimes sections would snap, sometimes they wouldn’t. Mobile was completely broken - touch scrolling felt like fighting the page.
The Investigation
flowchart TD
subgraph LENIS["Lenis Behavior"]
L1["Intercepts wheel/touch events"]
L2["Applies custom animation via rAF"]
L3["Updates scroll position programmatically"]
L1 --> L2 --> L3
end
subgraph SNAP["Scroll-Snap Behavior"]
S1["Expects native scroll"]
S2["Browser handles snap points"]
S3["No external position updates"]
S1 --> S2 --> S3
end
subgraph CONFLICT["When Combined"]
C1["Lenis animates scroll"]
C2["Browser tries to snap"]
C3["Lenis overrides snap"]
C4["Janky, broken behavior"]
C1 --> C2 --> C3 --> C4
end
LENIS --> CONFLICT
SNAP --> CONFLICT
style CONFLICT fill:#ef4444,color:#fff
The research confirmed it:
“CRITICAL: Lenis and CSS scroll-snap are INCOMPATIBLE - Lenis hijacks native scrolling”
Why They Fight
Lenis works by:
- Intercepting wheel and touch events
- Preventing default scroll behavior
- Animating scroll position via
requestAnimationFrame - Updating
window.scrollYprogrammatically
CSS scroll-snap expects:
- Native browser scroll events
- No interference with scroll position
- Browser control over snap point calculations
When combined:
- Lenis starts animating toward user’s intended scroll position
- Browser detects scroll and tries to snap to nearest point
- Lenis sees the snap as interference, overrides it
- Browser tries again
- Result: jittery, unpredictable scrolling
Mobile is worse because touch events are more complex, and Lenis’s touch handling conflicts even more with native snap behavior.
The Solution
Remove Lenis. Use CSS scroll-snap with native smooth scroll.
Before (Broken)
// Layout.astro
import Lenis from 'lenis';
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
});
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
html {
scroll-snap-type: y mandatory;
}
section {
scroll-snap-align: start;
}
After (Working)
// Layout.astro
// No Lenis - just GSAP ScrollTrigger
gsap.registerPlugin(ScrollTrigger);
// ScrollTrigger works with native scroll
ScrollTrigger.defaults({
toggleActions: 'play none none reverse',
});
html {
scroll-behavior: smooth;
scroll-snap-type: y proximity; /* proximity instead of mandatory */
}
section {
scroll-snap-align: start;
min-height: 100vh;
}
Key changes:
- Removed Lenis entirely - No more scroll hijacking
- Native
scroll-behavior: smooth- Browser handles smoothing proximityinstead ofmandatory- Less aggressive snapping, better UX- GSAP ScrollTrigger still works - It observes scroll, doesn’t control it
When to Use Each
| Want This? | Use This |
|---|---|
| Complex custom scroll animations | Lenis or Locomotive (no snap) |
| Parallax effects with snapping | CSS scroll-snap + ScrollTrigger |
| Section-based navigation | CSS scroll-snap only |
| Buttery smooth custom scrollbar | Lenis (no snap) |
| Mobile-friendly section snapping | CSS scroll-snap only |
The Decision Framework
Do you need scroll-snap?
├── YES
│ └── Do you need custom scroll physics?
│ ├── YES → Reconsider. They don't mix.
│ └── NO → Use CSS scroll-snap + native smooth scroll
└── NO
└── Do you need custom scroll physics?
├── YES → Use Lenis or Locomotive
└── NO → Use native scroll (nothing needed)
Alternative: GSAP ScrollTrigger Snap
If you need both smooth animations AND snapping, GSAP’s ScrollTrigger has a snap option that works with native scroll:
ScrollTrigger.create({
snap: {
snapTo: 1 / (sections.length - 1),
duration: { min: 0.2, max: 0.3 },
ease: "power1.inOut",
},
});
This provides snap-like behavior without hijacking scroll events.
Key Takeaways
- Lenis hijacks scroll, snap expects native - Fundamental incompatibility
- Mobile breaks first - Touch events are more sensitive to this conflict
- Native smooth scroll exists -
scroll-behavior: smoothis often enough - Proximity > mandatory - Less aggressive snapping improves UX
- Test on mobile early - Scroll behavior differences show there first
Don’t mix scroll-hijacking libraries with CSS scroll-snap. Choose one approach:
- Complex custom scroll → Lenis, no snap
- Section-based navigation → CSS scroll-snap, no Lenis
This debugging session cost 4 hours until the librarian agent found the incompatibility documented in Lenis issues. Check library compatibility before combining scroll behaviors.
