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
| Parameter | Effect | Recommended Value |
|---|---|---|
ease | Easing factor, controls follow speed | 0.05 ~ 0.15 |
intensity | Effect intensity, used to dynamically calculate ease | 5 ~ 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:
- Priority use of
offsetToplayer-by-layer accumulation, this property is not affected by transform - As fallback, temporarily remove transform to calculate position, then restore
- 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:
- Remove IntersectionObserver, switch to manual
getBoundingClientRect()calculation to detect element position - Listen to the
jelly-scrollcustom event to update active state during jelly scroll - 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.
| Feature | CSS scroll-behavior | Custom 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:
- Understanding interpolated animation: Smooth interpolation between target and current position
- Performance optimization: GPU acceleration, requestAnimationFrame
- Progressive enhancement: Only enable on desktop, keep native experience on mobile
- Ecosystem collaboration: Work with other components through custom events