Claude-skill-registry 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.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/butter-smooth-scrolling" ~/.claude/skills/majiayu000-claude-skill-registry-butter-smooth-scrolling && rm -rf "$T"
manifest: skills/data/butter-smooth-scrolling/SKILL.md
source content

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

FeatureCSS
scroll-behavior: smooth
Butter Smooth Scrolling
Anchor clicksYesNo (use existing smooth-scroll)
Mouse wheelNoYes
TrackpadNoYes
TouchNoYes
Momentum feelNoYes
Configurable smoothnessNoYes
Performance impactNoneMinimal (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

  1. Create ButterScroll Component - Create
    components/ui/butter-scroll.tsx
  2. Add to Layout - Include component in locale layout (alongside PageTransition)
  3. Configure Options - Adjust lerp factor for desired smoothness
  4. Test - Verify smooth scrolling on all inputs
  5. 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

  1. GPU Acceleration:
    window.scrollTo
    is optimized by browsers
  2. requestAnimationFrame: Syncs with display refresh rate (60fps)
  3. Threshold cutoff: Stops animation when movement imperceptible
  4. Reduced motion: Completely disabled for accessibility
  5. 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

FeatureCompatibility
Smooth scroll anchor linksWorks (sync on scroll event)
Page transitionsWorks (component sibling)
Framer Motion animationsWorks (no conflict)
Fixed navbarWorks (scroll position accurate)
Lazy loading imagesWorks (native scroll events fire)
Intersection ObserverWorks (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

  • ButterScroll
    component created at
    components/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 component
  • app/[locale]/layout.tsx
    - Updated with ButterScroll
  • Cinematic butter-smooth scrolling on all inputs