butter-smooth-scrolling
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.
When & Why to Use This Skill
This Claude skill implements cinematic lerp-based smooth scrolling for React and Next.js applications. It solves the limitations of native CSS smooth scrolling by adding momentum and linear interpolation (lerp) to mouse wheel, trackpad, and touch inputs. It is designed to provide a premium, high-performance 'agency-style' feel while maintaining full accessibility and respecting system-level reduced motion preferences.
Use Cases
- Premium Portfolios: Creating high-end creative agency or personal portfolio websites that require a 'butter-smooth' momentum scrolling effect for a luxury feel.
- Visual Storytelling: Enhancing long-form landing pages and digital editorial content where fluid, interpolated scrolling improves the narrative flow.
- Cross-Input Standardization: Ensuring a consistent, smooth scrolling experience for users regardless of whether they use a traditional mouse wheel, a precision trackpad, or a touch screen.
- Performance-Optimized UI: Implementing sophisticated scroll behaviors using requestAnimationFrame (RAF) to ensure 60fps performance without heavy external libraries like Locomotive Scroll.
| 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-scrollskill 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 factor (0-1). Lower = smoother but slower.
* 0.06 = very smooth (cinematic)
* 0.12 = balanced (default)
* 0.18 = snappy
*/
lerp?: number;
/**
* Scroll sensitivity multiplier.
* 1.0 = normal, 0.5 = slower, 1.5 = faster
*/
sensitivity?: number;
/**
* Disable butter scroll for trackpad (use native)
*/
disableTrackpad?: boolean;
/**
* Disable butter scroll for touch (use native)
*/
disableTouch?: boolean;
/**
* Minimum velocity threshold to stop animation (performance)
*/
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);
// Linear interpolation function
const lerp = useCallback((start: number, end: number, factor: number) => {
return start + (end - start) * factor;
}, []);
// Get max scroll position
const getMaxScroll = useCallback(() => {
return document.documentElement.scrollHeight - window.innerHeight;
}, []);
// Clamp target scroll to valid range
const clampScroll = useCallback(
(value: number) => {
return Math.max(0, Math.min(getMaxScroll(), value));
},
[getMaxScroll]
);
// Animation loop
const animate = useCallback(() => {
// Calculate new scroll position
currentScrollY.current = lerp(
currentScrollY.current,
targetScrollY.current,
options.lerp
);
// Apply scroll position
window.scrollTo(0, currentScrollY.current);
// Check if we should continue animating
const diff = Math.abs(targetScrollY.current - currentScrollY.current);
if (diff > options.threshold) {
rafId.current = requestAnimationFrame(animate);
} else {
// Snap to final position
window.scrollTo(0, targetScrollY.current);
currentScrollY.current = targetScrollY.current;
isScrolling.current = false;
rafId.current = null;
}
}, [lerp, options.lerp, options.threshold]);
// Start animation if not running
const startAnimation = useCallback(() => {
if (!isScrolling.current) {
isScrolling.current = true;
currentScrollY.current = window.scrollY;
rafId.current = requestAnimationFrame(animate);
}
}, [animate]);
// Detect if wheel event is from trackpad
const isTrackpadEvent = useCallback((e: WheelEvent) => {
const now = Date.now();
const timeDelta = now - lastWheelTime.current;
lastWheelTime.current = now;
// Trackpad typically has smaller, more frequent deltas
return Math.abs(e.deltaY) < 50 && timeDelta < 50;
}, []);
// Wheel event handler (mouse wheel + trackpad)
const handleWheel = useCallback(
(e: WheelEvent) => {
// Check if trackpad and should be disabled
if (options.disableTrackpad && isTrackpadEvent(e)) {
return;
}
// Prevent default scroll
e.preventDefault();
// Normalize delta across browsers
const normalizedDelta =
e.deltaMode === 1
? e.deltaY * 20 // Line mode (Firefox)
: e.deltaY; // Pixel mode (Chrome, Safari)
// Update target scroll position
targetScrollY.current = clampScroll(
targetScrollY.current + normalizedDelta * options.sensitivity
);
// Start animation
startAnimation();
},
[
clampScroll,
isTrackpadEvent,
options.disableTrackpad,
options.sensitivity,
startAnimation,
]
);
// Touch start handler
const handleTouchStart = useCallback(
(e: TouchEvent) => {
if (options.disableTouch) return;
touchStartY.current = e.touches[0].clientY;
lastTouchY.current = e.touches[0].clientY;
// Sync current scroll position
if (!isScrolling.current) {
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
}
},
[options.disableTouch]
);
// Touch move handler
const handleTouchMove = useCallback(
(e: TouchEvent) => {
if (options.disableTouch) return;
const currentTouchY = e.touches[0].clientY;
const deltaY = lastTouchY.current - currentTouchY;
lastTouchY.current = currentTouchY;
// Prevent default to take over scroll
e.preventDefault();
// Update target (inverted because dragging down = scroll up)
targetScrollY.current = clampScroll(
targetScrollY.current + deltaY * options.sensitivity
);
// Start animation
startAnimation();
},
[clampScroll, options.disableTouch, options.sensitivity, startAnimation]
);
// Touch end handler - add momentum
const handleTouchEnd = useCallback(() => {
// Animation continues naturally due to lerp
}, []);
// Sync scroll position on resize or programmatic scroll
const syncScroll = useCallback(() => {
if (!isScrolling.current) {
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
}
}, []);
useEffect(() => {
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
// Skip all butter scroll behavior for accessibility
return;
}
// Initialize scroll position
targetScrollY.current = window.scrollY;
currentScrollY.current = window.scrollY;
// Add wheel listener with passive: false to allow preventDefault
window.addEventListener("wheel", handleWheel, { passive: false });
// Add touch listeners if not disabled
if (!options.disableTouch) {
window.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
}
// Sync on scroll events (for anchor links, programmatic scrolls)
window.addEventListener("scroll", syncScroll, { passive: true });
// Sync on resize
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,
]);
// This component renders nothing
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) {
// ... existing code ...
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:
// Ultra smooth (cinematic, slow response)
<ButterScroll lerp={0.06} />
// Smooth (default, balanced)
<ButterScroll lerp={0.12} />
// Responsive (faster, less momentum)
<ButterScroll lerp={0.18} />
// Quick (minimal smoothing, snappy)
<ButterScroll lerp={0.25} />
Step 4: Additional Options
// Default: all inputs enabled
<ButterScroll lerp={0.12} />
// Disable for trackpad only (use native trackpad scroll)
<ButterScroll lerp={0.12} disableTrackpad />
// Disable for touch only (use native touch scroll)
<ButterScroll lerp={0.12} disableTouch />
// Custom configuration
<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.scrollTois 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; // Skip all butter scroll behavior
}
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
syncScrollis 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
-
ButterScrollcomponent created atcomponents/ui/butter-scroll.tsx - Component added to
app/[locale]/layout.tsx - Lerp factor configured (default 0.12)
- Test: Mouse wheel scrolling feels smooth and momentum-like
- Test: Trackpad scrolling feels smooth
- Test: Touch scrolling feels smooth on mobile
- Test: Anchor links still work (smooth-scroll skill)
- Test: Page transitions work (page-transitions skill)
- Test: Reduced motion preference disables effect
- Test: 60fps maintained (Chrome DevTools Performance)
Output
After running this skill:
components/ui/butter-scroll.tsx- Lerp scroll componentapp/[locale]/layout.tsx- Updated with ButterScroll- Cinematic butter-smooth scrolling on all inputs