| name | butter-smooth-scrolling |
| description | Add cinematic lerp-based smooth scrolling for all scroll inputs (mouse wheel, trackpad, touch). Creates butter-smooth momentum scrolling like premium agency websites. Works alongside existing smooth-scroll anchor links. |
Butter Smooth Scrolling
Add cinematic, momentum-based smooth scrolling that interpolates scroll position using requestAnimationFrame. Creates the "butter smooth" feel seen on premium agency websites.
Important: How This Differs from CSS Smooth Scroll
| Feature | CSS scroll-behavior: smooth | Butter Smooth Scrolling |
|---|
| Anchor clicks | Yes | No (use existing smooth-scroll) |
| Mouse wheel | No | Yes |
| Trackpad | No | Yes |
| Touch | No | Yes |
| Momentum feel | No | Yes |
| Configurable smoothness | No | Yes |
| Performance impact | None | Minimal (RAF optimized) |
This skill complements the existing smooth-scroll skill - they work together, not as replacements.
Prerequisites
- Next.js App Router
- React 19
- Existing
smooth-scroll skill for anchor links (optional but recommended)
Workflow
- Create ButterScroll Component - Create
components/ui/butter-scroll.tsx
- Add to Layout - Include component in locale layout (alongside PageTransition)
- Configure Options - Adjust lerp factor for desired smoothness
- Test - Verify smooth scrolling on all inputs
- Accessibility - Confirm reduced motion preference is respected
Implementation
Step 1: Create ButterScroll Component
Create website/components/ui/butter-scroll.tsx:
"use client";
import { useEffect, useRef, useCallback } from "react";
interface ButterScrollOptions {
lerp?: number;
sensitivity?: number;
disableTrackpad?: boolean;
disableTouch?: boolean;
threshold?: number;
}
const defaultOptions: Required<ButterScrollOptions> = {
lerp: 0.12,
sensitivity: 1.0,
disableTrackpad: false,
disableTouch: false,
threshold: 0.5,
};
export function ButterScroll(props: ButterScrollOptions = {}) {
const options = { ...defaultOptions, ...props };
const targetScrollY = useRef(0);
const currentScrollY = useRef(0);
const rafId = useRef<number | null>(null);
const isScrolling = useRef(false);
const lastWheelTime = useRef(0);
const touchStartY = useRef(0);
const lastTouchY = useRef(0);
const lerp = useCallback((start: number, end: number, factor: number) => {
return start + (end - start) * factor;
}, []);
const getMaxScroll = useCallback(() => {
return document.documentElement.scrollHeight - window.innerHeight;
}, []);
const clampScroll = useCallback(
(value: number) => {
return Math.max(0, Math.min(getMaxScroll(), value));
},
[getMaxScroll]
);
const animate = useCallback(() => {
currentScrollY.current = lerp(
currentScrollY.current,
targetScrollY.current,
options.lerp
);
window.scrollTo(0, currentScrollY.current);
const diff = Math.abs(targetScrollY.current - currentScrollY.current);
if (diff > options.threshold) {
rafId.current = requestAnimationFrame(animate);
} else {
window.scrollTo(0, targetScrollY.current);
currentScrollY.current = targetScrollY.current;
isScrolling.current = false;
rafId.current = null;
}
}, [lerp, options.lerp, options.threshold]);
const startAnimation = useCallback(() => {
if (!isScrolling.current) {
isScrolling.current = true;
currentScrollY.current = window.scrollY;
rafId.current = requestAnimationFrame(animate);
}
}, [animate]);
const isTrackpadEvent = useCallback((e: WheelEvent) => {
const now = Date.now();
const timeDelta = now - lastWheelTime.current;
lastWheelTime.current = now;
return Math.abs(e.deltaY) < 50 && timeDelta < 50;
}, []);
const handleWheel = useCallback(
(e: WheelEvent) => {
if (options.disableTrackpad && isTrackpadEvent(e)) {
return;
}
e.preventDefault();
const normalizedDelta =
e.deltaMode === 1
? e.deltaY * 20
: e.deltaY;
targetScrollY.current = clampScroll(
targetScrollY.current + normalizedDelta * options.sensitivity
);
startAnimation();
},
[
clampScroll,
isTrackpadEvent,
options.disableTrackpad,
options.sensitivity,
startAnimation,
]
);
const handleTouchStart = useCallback(
(e: TouchEvent) => {
if (options.disableTouch) return;
touchStartY.current = e.touches[0].clientY;
lastTouchY.current = e.touches[0].clientY;
if (!isScrolling.current) {
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
}
},
[options.disableTouch]
);
const handleTouchMove = useCallback(
(e: TouchEvent) => {
if (options.disableTouch) return;
const currentTouchY = e.touches[0].clientY;
const deltaY = lastTouchY.current - currentTouchY;
lastTouchY.current = currentTouchY;
e.preventDefault();
targetScrollY.current = clampScroll(
targetScrollY.current + deltaY * options.sensitivity
);
startAnimation();
},
[clampScroll, options.disableTouch, options.sensitivity, startAnimation]
);
const handleTouchEnd = useCallback(() => {
}, []);
const syncScroll = useCallback(() => {
if (!isScrolling.current) {
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
}
}, []);
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
return;
}
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
window.addEventListener("wheel", handleWheel, { passive: false });
if (!options.disableTouch) {
window.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
}
window.addEventListener("scroll", syncScroll, { passive: true });
window.addEventListener("resize", syncScroll, { passive: true });
return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
window.removeEventListener("scroll", syncScroll);
window.removeEventListener("resize", syncScroll);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
}
};
}, [
handleWheel,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
syncScroll,
options.disableTouch,
]);
return null;
}
Step 2: Add to Layout
Add ButterScroll to app/[locale]/layout.tsx:
import { PageTransition } from "@/components/ui/page-transition";
import { ButterScroll } from "@/components/ui/butter-scroll";
import { Navbar2 } from "@/components/navbar2";
import { Footer2 } from "@/components/footer2";
export default async function LocaleLayout({
children,
params,
}: Props) {
return (
<NextIntlClientProvider messages={messages}>
<PageTransition />
<ButterScroll lerp={0.12} />
<Navbar2 />
<main>{children}</main>
<Footer2 />
</NextIntlClientProvider>
);
}
Important:
- ButterScroll is a sibling component, not a wrapper
- Place it early in the component tree (after PageTransition)
- It renders nothing to the DOM
Step 3: Configure Smoothness
Adjust the lerp prop based on desired feel:
<ButterScroll lerp={0.06} />
<ButterScroll lerp={0.12} />
<ButterScroll lerp={0.18} />
<ButterScroll lerp={0.25} />
Step 4: Additional Options
<ButterScroll lerp={0.12} />
<ButterScroll lerp={0.12} disableTrackpad />
<ButterScroll lerp={0.12} disableTouch />
<ButterScroll
lerp={0.1} // Smoothness factor
sensitivity={1.2} // Scroll speed multiplier
disableTrackpad={false} // Enable for trackpad
disableTouch={false} // Enable for touch
threshold={0.3} // Animation stop threshold
/>
How It Works
1. User scrolls (wheel/trackpad/touch)
↓
2. Event captured (preventDefault)
↓
3. Target scroll position updated (current + delta)
↓
4. requestAnimationFrame loop starts
↓
5. Each frame: currentScroll = lerp(currentScroll, targetScroll, 0.12)
↓
6. window.scrollTo(0, currentScroll)
↓
7. Loop continues until difference < threshold
↓
8. Snap to final position, stop animation
Lerp (Linear Interpolation) Explained
lerp(start, end, factor) = start + (end - start) * factor
Example with factor 0.12:
- Frame 1: 0 + (100 - 0) * 0.12 = 12
- Frame 2: 12 + (100 - 12) * 0.12 = 22.56
- Frame 3: 22.56 + (100 - 22.56) * 0.12 = 31.85
- ... gradually approaches 100
Lower factor = more frames to reach target = smoother feel
Performance Considerations
- GPU Acceleration:
window.scrollTo is optimized by browsers
- requestAnimationFrame: Syncs with display refresh rate (60fps)
- Threshold cutoff: Stops animation when movement imperceptible
- Reduced motion: Completely disabled for accessibility
- No DOM manipulation: Scroll position only, no transform hacks
Accessibility
The component automatically respects prefers-reduced-motion:
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
return;
}
Users with reduced motion preference get native browser scrolling.
Compatibility with Other Features
| Feature | Compatibility |
|---|
| Smooth scroll anchor links | Works (sync on scroll event) |
| Page transitions | Works (component sibling) |
| Framer Motion animations | Works (no conflict) |
| Fixed navbar | Works (scroll position accurate) |
| Lazy loading images | Works (native scroll events fire) |
| Intersection Observer | Works (scroll position updates) |
What This Skill Does NOT Do
- Handle keyboard scrolling (arrow keys, Page Up/Down stay native)
- Provide scroll-linked animations (use Framer Motion)
- Work when reduced motion is enabled (accessibility)
Troubleshooting
Scroll feels choppy
- Increase lerp factor:
lerp={0.15}
- Check for heavy JavaScript blocking main thread
Anchor links don't work
- Ensure smooth-scroll skill is active
- Check
syncScroll is being called on scroll events
Scroll is too slow
- Increase sensitivity:
sensitivity={1.5}
- Increase lerp:
lerp={0.18}
Touch feels wrong on specific device
- Try
disableTouch={true} to use native touch scroll
Checklist
Output
After running this skill:
components/ui/butter-scroll.tsx - Lerp scroll component
app/[locale]/layout.tsx - Updated with ButterScroll
- Cinematic butter-smooth scrolling on all inputs