Skip to main content

React Advanced Scroll Animation

Advanced scroll effects go beyond progress bars and parallax. They include velocity-based animations that respond to scroll speed, morph animations that transform between states as the user scrolls, and staggered effects that cascade across multiple elements. These techniques require precise scroll tracking, easing calculations, and careful state management. In 2026, scroll-driven animations create immersive storytelling experiences and distinguish premium interfaces from generic ones.

Detecting and Responding to Scroll Velocity

Scroll velocity is the rate of scroll change. Fast scrolling indicates the user is skimming, while slow scrolling suggests focused reading. Use this to trigger different animations:

import { useEffect, useRef, useState } from 'react';

export function useScrollVelocity(threshold = 100) {
const [velocity, setVelocity] = useState(0);
const [isFastScroll, setIsFastScroll] = useState(false);
const lastScrollRef = useRef(0);
const lastTimeRef = useRef(Date.now());

useEffect(() => {
const handleScroll = () => {
const currentScroll = window.scrollY;
const currentTime = Date.now();

const scrollDelta = currentScroll - lastScrollRef.current;
const timeDelta = Math.max(currentTime - lastTimeRef.current, 1);

const currentVelocity = scrollDelta / timeDelta;
setVelocity(currentVelocity);
setIsFastScroll(Math.abs(currentVelocity) > threshold / 1000);

lastScrollRef.current = currentScroll;
lastTimeRef.current = currentTime;
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [threshold]);

return { velocity, isFastScroll };
}

export default function ScrollVelocityExample() {
const { velocity, isFastScroll } = useScrollVelocity(50);

return (
<>
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 1000, backgroundColor: '#fff', padding: '12px 16px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<p style={{ margin: 0, fontSize: '12px' }}>Velocity: {velocity.toFixed(3)} px/ms</p>
<p style={{ margin: '4px 0 0 0', fontSize: '12px', fontWeight: 'bold', color: isFastScroll ? '#ef4444' : '#10b981' }}>
{isFastScroll ? 'Fast Scroll' : 'Slow Scroll'}
</p>
</div>

<div style={{ minHeight: '2000px', padding: '40px' }}>
{Array.from({ length: 30 }, (_, i) => (
<p key={i} style={{ lineHeight: 1.6, color: '#333' }}>
Paragraph {i + 1}: Scroll slowly or quickly to see velocity detection change.
</p>
))}
</div>
</>
);
}

Calculate velocity by dividing scroll distance by elapsed time. Higher velocity (pixels per millisecond) indicates fast scrolling.

Morphing Elements During Scroll

Create elements that morph shape or color as the user scrolls through a page. This requires mapping scroll position to transformation values:

import { useEffect, useState } from 'react';

export default function MorphingScrollAnimation() {
const [morphProgress, setMorphProgress] = useState(0);

useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const maxScroll = 800;
const progress = Math.min(scrollY / maxScroll, 1);
setMorphProgress(progress);
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);

// Interpolate values based on scroll progress
const borderRadius = 8 + morphProgress * 192; // 8px to 200px (circle)
const backgroundColor = `hsl(${200 + morphProgress * 160}, 70%, 60%)`; // Blue to pink
const width = 100 + morphProgress * 150; // 100px to 250px
const height = 100 + morphProgress * 150;

return (
<div style={{ minHeight: '2000px', paddingTop: '100px' }}>
<div style={{ position: 'sticky', top: '100px', display: 'flex', justifyContent: 'center', padding: '40px' }}>
<div
style={{
width: `${width}px`,
height: `${height}px`,
backgroundColor,
borderRadius: `${borderRadius}px`,
transition: 'all 0.05s ease-out',
boxShadow: `0 ${4 + morphProgress * 12}px ${12 + morphProgress * 24}px rgba(0, 0, 0, ${0.1 + morphProgress * 0.2})`,
}}
/>
</div>

<div style={{ padding: '40px', backgroundColor: '#f9f9f9', borderTop: '1px solid #ddd' }}>
<h2>Scroll Progress: {(morphProgress * 100).toFixed(0)}%</h2>
<p>The shape above morphs from a square to a circle as you scroll. Color also transitions from blue to pink.</p>
</div>
</div>
);
}

Map scroll position (0 to 100%) to CSS property values. In this example, borderRadius goes from 8px (sharp corners) to 200px (perfect circle). Use hsl() for smooth color interpolation.

Staggered Element Animations on Scroll

Trigger staggered animations for lists where each item animates in sequence:

import { useEffect, useRef } from 'react';

export default function StaggeredScrollAnimation() {
const itemsRef = useRef([]);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry, index) => {
if (entry.isIntersecting) {
entry.target.style.animation = `slideInLeft 0.6s ease-out ${index * 0.1}s forwards`;
}
});
},
{ threshold: 0.1 }
);

itemsRef.current.forEach((item) => {
if (item) observer.observe(item);
});

return () => observer.disconnect();
}, []);

return (
<div style={{ padding: '40px' }}>
<style>{`
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}</style>

<h1>Staggered List Animation</h1>

<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginTop: '40px' }}>
{Array.from({ length: 12 }, (_, i) => (
<div
key={i}
ref={(el) => {
if (el) itemsRef.current[i] = el;
}}
style={{
padding: '20px',
backgroundColor: `hsl(${i * 30}, 70%, 80%)`,
borderRadius: '8px',
opacity: 0,
}}
>
<h3>Item {i + 1}</h3>
<p>This item animates in with a staggered delay.</p>
</div>
))}
</div>
</div>
);
}

Combine IntersectionObserver with CSS animation delays to create a waterfall effect. Each item's delay is index * 0.1 seconds, so items animate in sequence.

Scroll-Linked Text Reveal with Letter Reveal

Reveal text letter-by-letter as the user scrolls:

import { useEffect, useState } from 'react';

export default function LetterRevealOnScroll() {
const [revealCount, setRevealCount] = useState(0);

useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const maxScroll = 1000;
const revealProgress = Math.min(scrollY / maxScroll, 1);
const count = Math.floor(revealProgress * 100);
setRevealCount(count);
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);

const fullText = 'Scroll to reveal this text letter by letter. Each letter fades in as you progress through the page.';
const displayText = fullText.substring(0, revealCount);

return (
<div style={{ minHeight: '1500px', padding: '40px' }}>
<div style={{ fontSize: '24px', lineHeight: 1.6, maxWidth: '600px', height: '200px' }}>
<span style={{ color: '#333' }}>{displayText}</span>
<span style={{ color: '#ccc' }}>{fullText.substring(revealCount)}</span>
</div>

<p style={{ marginTop: '40px', color: '#666', fontSize: '14px' }}>
Progress: {revealCount} / {fullText.length} characters
</p>
</div>
);
}

Calculate the reveal progress based on scroll position, then use substring() to show only the revealed portion of the text. The unrevealed portion is rendered in a light gray color.

Key Takeaways

  • Scroll velocity is calculated as scrollDelta / timeDelta (pixels per millisecond); use thresholds to distinguish fast and slow scrolling.
  • Morph elements by mapping scroll progress (0–1) to CSS values using interpolation; hsl() color space works well for smooth color transitions.
  • Stagger element animations with IntersectionObserver combined with CSS animation-delay; each element's delay is index * baseDelay.
  • Letter-by-letter text reveal uses substring() to progressively show text based on scroll position, creating a cinema-like text animation.

Frequently Asked Questions

How do I prevent jank when morphing multiple elements simultaneously?

Use will-change: transform (or will-change: all) in CSS to hint the browser. Avoid layout changes (width, height) during morph animations; use transform instead. Batch DOM updates and throttle state updates to 60 FPS.

Can I make scroll-driven animations work on mobile with momentum scrolling?

Yes, scroll events still fire during momentum scroll on iOS and Android. However, momentum scroll decelerates over time, so animations may feel less responsive. Test on actual devices and adjust thresholds accordingly.

How do I sync scroll-driven animations with Framer Motion?

Use useScroll from Framer Motion (if available) or combine scroll listeners with Framer Motion's useMotionValue and useTransform. Create a scroll motion value, then transform it to drive Framer animations.

Should I use requestAnimationFrame to throttle scroll-driven animations?

For simple state updates, scroll event throttling (with a ref timeout) is sufficient. For complex layouts or many animated elements, use requestAnimationFrame to batch DOM updates at 60 FPS, avoiding jank.

How do I make scroll animations accessible?

Respect prefers-reduced-motion media query. Add a CSS rule: @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }. This disables animations for users with vestibular disorders.

Further Reading