| name | scroll-animation-builder |
| description | This skill should be used when the user wants animations that are triggered by or linked to scroll position. Trigger when the user mentions "scroll animation", "scroll-triggered", "on scroll", "scroll reveal", "fade in on scroll", "parallax", "horizontal scroll", "sticky section", "scroll-linked animation", "pin section on scroll", "scroll progress", "Intersection Observer", "GSAP ScrollTrigger", "useScroll Framer Motion", or wants elements to animate as the user scrolls. Also trigger when the user asks about "hero animation", "scroll storytelling", or mentions a landing page needs to feel "alive while scrolling". |
Scroll-Driven Animation Builder
Scroll animations come in two categories with fundamentally different implementation approaches:
- Scroll-triggered — Animations that play once when an element enters the viewport. Uses IntersectionObserver. Time-based, not scroll-position-based.
- Scroll-linked — Animation progress is directly tied to scroll position. Every scroll tick drives the animation value. Uses
useScroll + useTransform or GSAP ScrollTrigger.
Choosing the wrong type for a scenario is the single most common scroll animation mistake.
| When to use scroll-triggered | When to use scroll-linked |
|---|
| Element should animate in once and stay | Animation progress mirrors scroll position |
| Content reveals as user reads down | Parallax depth effects |
| Performance is critical (runs once) | Sticky section transitions |
| "Reveal on scroll" pattern | Scroll progress indicators |
| Most content sections in a product page | Hero storytelling sequences |
Scroll-Triggered Animations with IntersectionObserver
Why IntersectionObserver: It fires only when elements enter/exit the viewport and runs off the main thread — zero scroll event listener overhead, no debouncing needed.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{
threshold: 0.15,
rootMargin: "0px",
}
);
document.querySelectorAll("[data-animate]").forEach((el) => observer.observe(el));
[data-animate] {
opacity: 0;
transform: translateY(16px);
transition: opacity 280ms ease-out, transform 280ms ease-out;
}
[data-animate]:nth-child(2) { transition-delay: 60ms; }
[data-animate]:nth-child(3) { transition-delay: 120ms; }
[data-animate]:nth-child(4) { transition-delay: 180ms; }
[data-animate].is-visible {
opacity: 1;
transform: translateY(0);
}
Never animate elements already in the viewport on page load. This punishes fast connections and makes the page look broken. Check the element's initial position before observing.
Framer Motion variant — whileInView:
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.28, ease: [0, 0, 0.58, 1] }}
>
Content
</motion.div>
Staggered list reveal:
function AnimatedList({ items }) {
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.1,
},
},
};
const item = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.26, ease: [0, 0, 0.58, 1] } },
};
return (
<motion.ul variants={container} initial="hidden" whileInView="visible" viewport={{ once: true }}>
{items.map((i) => (
<motion.li key={i} variants={item}>{i}</motion.li>
))}
</motion.ul>
);
}
Scroll-Linked Animations with Framer Motion
import { useScroll, useTransform, motion } from "framer-motion";
import { useRef } from "react";
function ParallaxSection() {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], [60, -60]);
const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
return (
<section ref={ref}>
<motion.div style={{ y, opacity }}>
Parallax content
</motion.div>
</section>
);
}
Scroll progress indicator:
function ScrollProgress() {
const { scrollYProgress } = useScroll();
return (
<motion.div
className="scroll-progress-bar"
style={{ scaleX: scrollYProgress, transformOrigin: "left" }}
/>
);
}
Scroll-Linked Animations with GSAP ScrollTrigger
Use GSAP when: multiple elements need precise choreography tied to scroll, you need pinning (keeping a section fixed while scrolling past it), or you're building a complex editorial/marketing sequence.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
gsap.timeline({
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "+=300%",
pin: true,
scrub: 1,
},
})
.from(".hero-title", { y: 60, opacity: 0 })
.from(".hero-subtitle", { y: 40, opacity: 0 }, "-=0.5")
.from(".hero-image", { scale: 0.9, opacity: 0 }, "-=0.3");
Horizontal scroll section:
gsap.to(".horizontal-track", {
xPercent: -100 * (sections - 1),
ease: "none",
scrollTrigger: {
trigger: ".horizontal-container",
pin: true,
scrub: 1,
end: () => "+=" + document.querySelector(".horizontal-track").offsetWidth,
},
});
Parallax — Three-Layer Rule
Minimum 3 layers for convincing depth:
const { scrollY } = useScroll();
const bgY = useTransform(scrollY, [0, 1000], [0, -300]);
const subjectY = useTransform(scrollY, [0, 1000], [0, -100]);
const fgY = useTransform(scrollY, [0, 1000], [0, 130]);
Maximum displacement in product UIs: 15–20px total travel. Marketing pages can push to 60px. Beyond that, content becomes unreadable mid-scroll.
Always gate parallax on prefers-reduced-motion — it is the most common vestibular trigger in web UI.
Performance Rules for Scroll Animations
Scroll-linked animations run on every scroll tick. Any performance mistake here produces visible jank because there is no idle time to recover.
- Exclusively animate
transform and opacity — never top, left, width, or any layout property
will-change: transform on elements with scroll-linked transforms — promotes to GPU layer before animation begins
contain: layout style paint on scroll animation containers — limits layout recalc scope
- Never use GSAP or JS-driven scroll animations for simple reveals — IntersectionObserver is lighter and fires once
- Throttle non-critical scroll events — if you must use scroll event listeners, use
{ passive: true } and throttle to requestAnimationFrame
window.addEventListener("scroll", handler, { passive: true });
let ticking = false;
function handler() {
if (!ticking) {
requestAnimationFrame(() => { updateAnimation(); ticking = false; });
ticking = true;
}
}
Additional Resources
references/scroll-patterns.md — CSS animation-timeline: scroll() (native standard), SVG path animation on scroll, horizontal scroll galleries, scroll-triggered counters, and mobile-specific scroll considerations