Back to Blog
J

Jelly-like Smooth Scrolling: Build a Custom Scrolling Component from Scratch

April 22, 2026·8 min read
ReactUXAnimationTypeScriptFrontend

Table of Contents

  • Introduction
  • Core Concepts
  • The Essence of Jelly Scrolling
  • Key Parameters
  • Implementation Approach
  • Implementation Steps
  • Step 1: Basic Architecture
  • Step 2: Device Detection
  • Step 3: Handling Wheel Events
  • Step 4: Animation Loop
  • Step 5: Handling Styles
  • Performance Optimization
  • 1. Avoid Closure Traps
  • 2. GPU Acceleration
  • 3. Using `will-change`
  • Integration with Other Components
  • 1. Navigation Anchor Links
  • 2. Navigation Active Section Detection (Indicator Bar)
  • 3. Scroll-to-Top Button
  • 4. Fixed Position Elements
  • Pitfalls Encountered
  • Pitfall 1: Anchor Links Only Working Downward
  • Pitfall 2: Navigation Indicator Bar Disappears
  • Common Questions
  • 1. Why Not Just Use CSS `scroll-behavior`?
  • 2. How to Disable the Effect?
  • Summary

Table of Contents

  • Introduction
  • Core Concepts
  • The Essence of Jelly Scrolling
  • Key Parameters
  • Implementation Approach
  • Implementation Steps
  • Step 1: Basic Architecture
  • Step 2: Device Detection
  • Step 3: Handling Wheel Events
  • Step 4: Animation Loop
  • Step 5: Handling Styles
  • Performance Optimization
  • 1. Avoid Closure Traps
  • 2. GPU Acceleration
  • 3. Using `will-change`
  • Integration with Other Components
  • 1. Navigation Anchor Links
  • 2. Navigation Active Section Detection (Indicator Bar)
  • 3. Scroll-to-Top Button
  • 4. Fixed Position Elements
  • Pitfalls Encountered
  • Pitfall 1: Anchor Links Only Working Downward
  • Pitfall 2: Navigation Indicator Bar Disappears
  • Common Questions
  • 1. Why Not Just Use CSS `scroll-behavior`?
  • 2. How to Disable the Effect?
  • Summary

Introduction

While browsing template websites, I discovered a special scrolling experience—page content follows with an elastic, laggy feeling, like sliding a piece of jelly. This subtle interaction detail significantly enhances the premium feel of a website.

After analyzing the source code, I found the implementation principles are not complicated, and can be built without any third-party libraries. This article will guide you through building this component from scratch.

Core Concepts

The Essence of Jelly Scrolling

This effect is based on three core concepts:

// 1. Target scroll position (actual scroll position)
targetY += deltaY;

// 2. Current scroll position (smoothed position)
currentY += (targetY - currentY) * ease;

// 3. Apply to DOM
container.style.transform = `translate3d(0, ${-currentY}px, 0)`;

Key Parameters

ParameterEffectRecommended Value
easeEasing factor, controls follow speed0.05 ~ 0.15
intensityEffect intensity, used to dynamically calculate ease5 ~ 10

Implementation Approach

User scroll → Update target position → Interpolate each frame → Apply transform → Elastic follow effect
                ↓                    ↓                     ↓
            targetY          (target - current)         transform

Implementation Steps

Step 1: Basic Architecture

First, we need to add TypeScript declarations and a React component to wrap content and handle events:

// Global type declarations
declare global {
  interface Window {
    jellyScrollTo?: (y: number) => void;
    jellyScrollToElement?: (el: HTMLElement) => void;
    isJellyScrollActive?: boolean;
  }
}

// Custom event type
interface JellyScrollEvent extends CustomEvent {
  detail?: {
    scrollY: number;
  };
}

interface JellyScrollProps {
  children: React.ReactNode;
  enabled?: boolean;
  desktopOnly?: boolean;
  intensity?: number;
}

export function JellyScroll({
  children,
  enabled = true,
  desktopOnly = true,
  intensity = 6,
}: JellyScrollProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const scrollY = useRef(0);
  const targetY = useRef(0);
  const isActive = useRef(false);
  const ease = 0.05 + (intensity * 0.006);
  
  // Rest of the code...
}

Step 2: Device Detection

This effect works best on desktop; mobile should keep native scrolling:

const shouldEnable = useCallback(() => {
  if (!enabled) return false;
  if (!desktopOnly) return true;
  return window.innerWidth >= 768;
}, [enabled, desktopOnly]);

Step 3: Handling Wheel Events

const handleWheel = useCallback((e: WheelEvent) => {
  if (!isActive.current) return;
  e.preventDefault();
  
  let delta = e.deltaY;
  
  // Acceleration effect: faster scroll, larger increment
  const speed = Math.abs(delta);
  if (speed > 80) {
    delta *= 1.4;
  } else if (speed > 40) {
    delta *= 1.2;
  }
  
  targetY.current += delta;
  
  if (!containerRef.current) return;
  
  // Constrain to reasonable limits
  const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
  targetY.current = Math.max(0, Math.min(targetY.current, maxScroll));
  
  // Start animation loop
  if (!isScrolling.current) {
    startAnimation();
  }
}, [isActive]);

Step 4: Animation Loop

Use requestAnimationFrame for smooth frame updates:

const animate = () => {
  if (!containerRef.current) return;
  
  const diff = targetY.current - scrollY.current;
  
  // Apply easing
  const step = Math.min(Math.abs(diff) * ease, Math.abs(diff));
  if (Math.abs(diff) > 0.1) {
    scrollY.current += diff > 0 ? step : -step;
  } else {
    scrollY.current = targetY.current;
    isScrolling.current = false;
  }
  
  // Constrain to limits
  const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
  scrollY.current = Math.max(0, Math.min(scrollY.current, maxScroll));
  
  // Apply transform - use translate3d to trigger GPU acceleration
  containerRef.current.style.transform = `translate3d(0, ${-scrollY.current}px, 0)`;
  
  // Trigger custom event for other components to use
  const event = new CustomEvent('jelly-scroll', { 
    detail: { scrollY: scrollY.current } 
  });
  window.dispatchEvent(event);
  
  if (isScrolling.current) {
    requestAnimationFrame(animate);
  }
};

Step 5: Handling Styles

// Set styles when active
const el = containerRef.current;
el.style.position = 'fixed';
el.style.top = '0';
el.style.left = '0';
el.style.width = '100%';
el.style.willChange = 'transform';  // Hint browser to optimize

document.body.style.overflow = 'hidden';
document.body.style.height = '100%';

Performance Optimization

1. Avoid Closure Traps

Initial implementation encountered React closure traps—the animate function referenced in useCallback might be an old version. The solution is using ref to hold function references:

const animateRef = useRef<() => void>();

const setupAnimate = useCallback(() => {
  const animate = () => {
    // Animation logic
  };
  animateRef.current = animate;
}, [ease]);

// Usage
requestAnimationFrame(animateRef.current as () => void);

2. GPU Acceleration

Use translate3d instead of translateY:

// Good: Trigger GPU acceleration
container.style.transform = `translate3d(0, ${-scrollY}px, 0)`;

// Not recommended: CPU rendering only
container.style.transform = `translateY(${-scrollY}px)`;

3. Using will-change

Tell the browser beforehand that this element will have transformations:

container.style.willChange = 'transform';

Integration with Other Components

1. Navigation Anchor Links

The jelly scroll effect affects normal anchor scrolling, so the navigation bar needs to support jelly scrolling. Special attention should be paid to position calculation—in containers with transform applied, getBoundingClientRect returns viewport-relative positions and needs special handling:

window.jellyScrollToElement = (el: HTMLElement) => {
  if (!containerRef.current || !isActive) {
    el.scrollIntoView({ behavior: 'smooth' });
    return;
  }
  
  let offsetTop = 0;
  let currentEl: HTMLElement | null = el;
  
  // Calculate position by accumulating offsetTop layer by layer (more reliable)
  while (currentEl && currentEl !== containerRef.current) {
    if (currentEl.offsetParent) {
      offsetTop += currentEl.offsetTop;
      currentEl = currentEl.offsetParent as HTMLElement;
    } else {
      break;
    }
  }
  
  // Fallback: if offsetTop calculation fails, use temp transform removal method
  if (offsetTop === 0) {
    const originalTransform = containerRef.current.style.transform;
    containerRef.current.style.transform = 'translate3d(0, 0, 0)';
    
    const elementRect = el.getBoundingClientRect();
    const containerRect = containerRef.current.getBoundingClientRect();
    
    offsetTop = elementRect.top - containerRect.top;
    containerRef.current.style.transform = originalTransform;
    offsetTop += scrollY.current;
  }
  
  // Subtract header height offset
  const headerHeight = 64;
  offsetTop = Math.max(0, offsetTop - headerHeight);
  
  if (window.jellyScrollTo) {
    window.jellyScrollTo(offsetTop);
  }
};

Navigation bar click handler:

const handleScrollClick = useCallback(
  (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
    if (!isHomePage) return;
    e.preventDefault();
    const el = document.getElementById(id);
    if (el) {
      if (window.jellyScrollToElement) {
        window.jellyScrollToElement(el);
      } else {
        el.scrollIntoView({ behavior: 'smooth' });
      }
    }
  },
  [isHomePage]
);

2. Navigation Active Section Detection (Indicator Bar)

In traditional scrolling, we often use IntersectionObserver to detect element visibility. But with jelly scrolling, since we use transform instead of actual scrolling, we need to use custom events and manual position checking:

const checkActiveSection = useCallback(() => {
  if (!isHomePage) return;
  
  let foundActive = false;
  
  // Check Hero section (no indicator)
  const heroEl = document.getElementById('hero');
  if (heroEl) {
    const heroRect = heroEl.getBoundingClientRect();
    const heroMiddle = heroRect.top + heroRect.height / 2;
    if (heroMiddle >= 0 && heroMiddle <= window.innerHeight * 0.7) {
      setActiveSection('');
      setIndicatorStyle(prev => ({ ...prev, opacity: 0 }));
      foundActive = true;
    }
  }
  
  // Check other sections
  if (!foundActive) {
    for (const item of NAV_ITEMS) {
      const el = document.getElementById(item);
      if (!el) continue;
      
      const rect = el.getBoundingClientRect();
      const elementMiddle = rect.top + rect.height / 2;
      if (elementMiddle >= window.innerHeight * 0.2 && 
          elementMiddle <= window.innerHeight * 0.7) {
        setActiveSection(item);
        foundActive = true;
        break;
      }
    }
  }
}, [isHomePage]);

useEffect(() => {
  if (!isHomePage) return;
  
  // Native scroll listener
  const handleNativeScroll = () => {
    if (!window.isJellyScrollActive) {
      currentScrollY.current = window.scrollY;
      checkActiveSection();
    }
  };
  window.addEventListener('scroll', handleNativeScroll);
  
  // Jelly scroll custom event listener
  const handleJellyScroll = (e: Event) => {
    const jellyEvent = e as CustomEvent<{ scrollY: number }>;
    if (jellyEvent.detail?.scrollY !== undefined) {
      currentScrollY.current = jellyEvent.detail.scrollY;
      checkActiveSection();
    }
  };
  window.addEventListener('jelly-scroll', handleJellyScroll);
  
  // Initial check (use setTimeout to avoid sync setState warning)
  setTimeout(checkActiveSection, 0);
  
  return () => {
    window.removeEventListener('scroll', handleNativeScroll);
    window.removeEventListener('jelly-scroll', handleJellyScroll);
  };
}, [isHomePage, checkActiveSection]);

3. Scroll-to-Top Button

Make the scroll-to-top button support jelly scrolling:

interface ScrollToTopButtonProps {
  useJellyScroll?: boolean;
}

export function ScrollToTopButton({ useJellyScroll = false }) {
  const [isVisible, setIsVisible] = useState(false);
  
  useEffect(() => {
    const toggleVisibility = (scrollPos?: number) => {
      const currentY = scrollPos !== undefined ? scrollPos : window.scrollY;
      setIsVisible(currentY > 300);
    };
    
    // Listen to native scroll
    window.addEventListener('scroll', () => !useJellyScroll && toggleVisibility());
    
    // Listen to custom jelly scroll event
    const handleJellyScroll = (e: Event) => {
      const jellyEvent = e as JellyScrollEvent;
      if (useJellyScroll && jellyEvent.detail?.scrollY !== undefined) {
        toggleVisibility(jellyEvent.detail.scrollY);
      }
    };
    window.addEventListener('jelly-scroll', handleJellyScroll);
    
    return () => {
      // Clean up listeners
    };
  }, [useJellyScroll]);
  
  const scrollToTop = () => {
    if (useJellyScroll && window.jellyScrollTo) {
      window.jellyScrollTo(0);
    } else {
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }
  };
  
  // Render button...
}

4. Fixed Position Elements

Fixed position elements should be placed outside the jelly scroll container:

export function ClientPortfolioWrapper({ children }) {
  return (
    <>
      {/* Fixed components outside the scroll container */}
      <MouseGlow />
      <Header />
      <ClientChatWidget />
      
      {/* Jelly scroll container */}
      <JellyScroll>
        <main>{children}</main>
        <Footer />
      </JellyScroll>
      
      <ScrollToTopButton useJellyScroll={true} />
    </>
  );
}

Pitfalls Encountered

Pitfall 1: Anchor Links Only Working Downward

Problem: Clicking navigation links only scrolled downward, couldn't scroll up to earlier sections.

Cause: Used getBoundingClientRect() to calculate position, but didn't account for the transform already applied to the container. When transform is present, this method returns incorrect position relative to the actual offset.

Solution:

  1. Priority use of offsetTop layer-by-layer accumulation, this property is not affected by transform
  2. As fallback, temporarily remove transform to calculate position, then restore
  3. Ensure both methods subtract the header height offset

Pitfall 2: Navigation Indicator Bar Disappears

Problem: When using jelly scroll, the small indicator bar below the navigation (showing active section) disappears.

Cause: The original implementation used IntersectionObserver, but jelly scroll uses transform instead of actual page scrolling, so IntersectionObserver doesn't work correctly.

Solution:

  1. Remove IntersectionObserver, switch to manual getBoundingClientRect() calculation to detect element position
  2. Listen to the jelly-scroll custom event to update active state during jelly scroll
  3. Keep listening to native scroll events for when jelly scroll is not enabled

Common Questions

1. Why Not Just Use CSS scroll-behavior?

scroll-behavior: smooth only smoothes anchor jumps, it cannot produce a continuous elastic follow effect.

FeatureCSS scroll-behaviorCustom Jelly Scroll
Smooth anchor clicks✅✅
Elastic wheel follow❌✅
Controllable intensity❌✅
Event integration❌✅

2. How to Disable the Effect?

Just set the enabled prop to false, and the component automatically falls back to native scrolling.

Summary

The implementation of the jelly scroll effect is not complicated, the core lies in:

  1. Understanding interpolated animation: Smooth interpolation between target and current position
  2. Performance optimization: GPU acceleration, requestAnimationFrame
  3. Progressive enhancement: Only enable on desktop, keep native experience on mobile
  4. Ecosystem collaboration: Work with other components through custom events