Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

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 Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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

  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

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

  • 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