How to Build Scroll Animations in React Without Turning the Page Into a Timeline Mess
Build maintainable scroll experiences by giving each section its own trigger, progress source, or scoped timeline instead of coupling the entire page to one master timeline.


Hitesh Bhardwaj
Sr. Developer
Category: Engineering
A page-wide scroll timeline often begins with one reasonable decision. The hero fades in. A feature section needs parallax, so it joins the timeline. Then a product story gets pinned, a screenshot swaps halfway through, and the testimonial heading needs to arrive slightly earlier.
Soon the page has one playhead and twenty things that need to know where they sit on it.
None of those effects is necessarily difficult on its own. The fragility appears because unrelated sections now depend on the same ordering, measurements and timing assumptions.
A cleaner architecture gives each section the smallest scroll mechanism it needs. Use a trigger when something responds to entering the viewport, local scroll progress when movement should track position, and a scoped timeline when several elements genuinely need to share one sequence.
The mistake is not using a timeline. It is making the whole page one timeline.
Timelines are useful when relative timing carries meaning. If a heading fades while a screenshot changes and an annotation follows, those events belong on the same playhead because they describe one coordinated interaction.
A hero reveal and a feature section three viewports below have no such relationship. Joining them creates a dependency the interface never asked for.
GSAP's ScrollTrigger supports both arrangements: a trigger can control a timeline, while independent ScrollTriggers can own separate start, end, scrub and pin behaviour. ScrollTrigger documentation
A page-wide controller tends to look conceptually like this:
ONE PAGE-WIDE PLAYHEAD
Hero — Feature story — Testimonials — CTA
└─ every section depends on shared timing ─┘Section ownership changes the dependency graph:
SECTION-OWNED MOTION
Hero
└─ reveal trigger
Feature story
└─ local progress + timeline
Testimonials
└─ independent visibility trigger
CTA
└─ staticThe second arrangement changes debugging as much as authoring. If the product story breaks after a CMS edit, the investigation stays inside the product story. You do not have to find which label in a page-wide timeline shifted because another section gained two paragraphs.
Sequence only what actually shares a beat
A sticky product narrative might keep one claim visible while three screenshots change beside it. Those screenshots are deliberately coupled. Their fades, annotations and state changes can reasonably live inside one timeline.
Three cards entering farther down the page do not need to know that sequence exists.
This boundary also protects the document itself. Headings, controls, screenshots and supporting copy should have a useful DOM order before animation changes their presentation. The page should remain understandable when motion is reduced, a dependency fails to initialize, or the mobile layout abandons the desktop choreography entirely.
First decide whether the interaction is a trigger, progress value or sequence
Library choice becomes much easier after the interaction has been classified.
Motion's current React documentation separates scroll-triggered animation, which responds to entering or leaving a viewport, from scroll-linked animation, where values follow scroll position continuously. Motion's React scroll-animation guide A third category becomes useful when building real pages: a sequence, where several states deliberately share one playhead.
| Interaction job | Example | Sensible starting point |
|---|---|---|
| Trigger | Reveal a card when it enters the viewport | IntersectionObserver, Motion whileInView |
| Progress | Parallax, progress bar, continuous transform | Motion useScroll, CSS scroll timeline |
| Sequence | Pinned feature story with coordinated states | Scoped GSAP timeline + ScrollTrigger |
A trigger answers whether something crossed a threshold. Progress answers how far through a range the user has moved. A sequence is justified when several visual changes need a shared sense of timing.
Trigger: respond to a threshold
An entrance reveal rarely needs continuous scroll measurement. Once the element crosses the required threshold, the job is done.
IntersectionObserver is designed for this kind of threshold-based visibility work: it asynchronously reports when a target crosses configured intersection ratios rather than requiring application code to measure its position continuously. MDN's Intersection Observer API guide
The static state should remain readable even when JavaScript never starts. This pattern therefore begins visible and opts into a hidden pre-animation state only after the enhancement initializes.
There is one important constraint: use this version for below-the-fold reveals. Because useEffect runs after the initial render, an element already visible during hydration can briefly paint in its final state before JavaScript marks it as pending. Hero content should normally render in its final readable state, or use a deliberately designed pre-paint strategy if the entrance itself is essential.
"use client";
import { useEffect, useRef } from "react";
export function Reveal({
children,
}: {
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (
!element ||
!("IntersectionObserver" in window) ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
element.dataset.reveal = "pending";
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
element.dataset.reveal = "visible";
observer.disconnect();
},
{ threshold: 0.2 }
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="reveal">
{children}
</div>
);
}
.reveal {
opacity: 1;
transform: none;
}
.reveal[data-reveal="pending"] {
opacity: 0;
transform: translateY(1rem);
}
.reveal[data-reveal="visible"] {
opacity: 1;
transform: translateY(0);
transition:
opacity 500ms ease,
transform 500ms ease;
}
Without JavaScript, IntersectionObserver support, or motion permission, the content stays visible. When the enhancement is available, the section owns one observer and disconnects it when its work is finished or the component unmounts.
A reveal can use this observer while a product story elsewhere uses ScrollTrigger. They do not need a shared controller merely because they appear on the same page.
Progress: derive motion from a local range
Parallax and progress indicators need continuous information. The useful value is often a normalized 0-1 representing how far a particular section has travelled through a defined range.
Motion's useScroll can target an individual element, so the progress source belongs to the section rather than automatically tracking the page as a whole.
"use client";
import { useRef } from "react";
import {
motion,
useReducedMotion,
useScroll,
useTransform,
} from "motion/react";
export function LocalParallax() {
const section = useRef<HTMLElement>(null);
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({
target: section,
offset: ["start end", "end start"],
});
const y = useTransform(
scrollYProgress,
[0, 1],
reduceMotion ? [0, 0] : [40, -40]
);
return (
<section ref={section}>
<motion.img
src="/product-screen.webp"
alt="Product interface"
style={{ y }}
/>
</section>
);
}
The forty-pixel translation is incidental. The architectural choice is that the section owns both its progress range and the visual value derived from it.
CSS scroll-driven animation can handle similar jobs without a JavaScript animation runtime when the required browser support and fallback make sense. animation-timeline can bind CSS animation progress to a scroll or view timeline, although MDN currently marks the property as Limited availability, so production use still needs progressive enhancement where the target browser set demands it. MDN's animation-timeline reference
Sequence: share a playhead deliberately
A timeline starts earning its complexity when several changes have meaningful relative timing.
A pinned product story might hold one frame in place while visual evidence changes through several states. Scrubbing can make those transitions occupy deliberate portions of the section's scroll range. ScrollTrigger is well suited to this kind of choreography because pinning, scrubbed progress and timeline sequencing belong to the same interaction.
The useful boundary is the component containing that story.
Use the smallest scroll driver that can do the job
A fade-up does not become better because GSAP controls it. A scrubbed product narrative does not become simpler because somebody insisted on solving it with browser APIs alone
| Approach | Good fit | Meaningful boundary |
|---|---|---|
| IntersectionObserver | Visibility-driven reveals and state changes | Not intended to provide continuous scrubbed progress |
| CSS scroll timelines | Direct scroll-linked CSS effects | Requires a fallback where support is insufficient |
| Motion | React-friendly triggered and progress-linked animation | Complex multi-stage pinning may benefit from a more explicit timeline |
| GSAP ScrollTrigger | Pinning, scrubbing and coupled choreography | More machinery than a simple visibility event needs |
Treat dependencies the same way. A reveal can depend on browser APIs while a feature narrative uses GSAP. There is no architectural prize for forcing both through one animation abstraction.
Vault follows this effect-specific model as well. Its dependency documentation distinguishes structural requirements from peer engines such as GSAP and Motion, with those engines attached to effects that actually need them rather than installed as universal animation machinery. Vault dependency documentation.
Scope animation to the component that owns it
React already gives imperative animation a useful lifecycle boundary.
If <FeatureStory /> creates ScrollTriggers, that component should own their cleanup. If <Testimonials /> creates an observer, its lifecycle should not be managed by a global page script.
For GSAP-heavy React work, useGSAP() from @gsap/react provides the component-level lifecycle boundary. Responsive animation adds another context, however: gsap.matchMedia() creates its own MatchMedia context. If selector strings are used inside the media-query handler, scope that MatchMedia instance explicitly to the component as well.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function FeatureStory() {
const root = useRef<HTMLElement>(null);
useGSAP(
() => {
const media = gsap.matchMedia(root);
media.add(
"(min-width: 768px) and (prefers-reduced-motion: no-preference)",
() => {
const timeline = gsap.timeline({
scrollTrigger: {
trigger: root.current,
start: "top top",
end: () => `+=${window.innerHeight * 2}`,
scrub: true,
pin: ".story__frame",
invalidateOnRefresh: true,
},
});
timeline
.to("[data-shot='one']", { autoAlpha: 0 })
.fromTo(
"[data-shot='two']",
{ autoAlpha: 0 },
{ autoAlpha: 1 }
)
.to("[data-shot='two']", { autoAlpha: 0 })
.fromTo(
"[data-shot='three']",
{ autoAlpha: 0 },
{ autoAlpha: 1 }
);
}
);
return () => media.revert();
},
{ scope: root }
);
return (
<section ref={root}>
<div className="story__frame">
{/* Stable copy and changing visual states */}
</div>
</section>
);
}
GSAP documents the MatchMedia scope specifically for this purpose: selector text inside its handler can be constrained to a DOM element or React ref rather than resolving against the whole document. GSAP matchMedia() documentation
There are now two explicit boundaries in the example. useGSAP() owns the component-level animation lifecycle; the MatchMedia context owns the responsive setup and scopes its selector strings to root. When the query stops matching or the component disappears, that responsive animation state can be reverted without reaching into unrelated sections.
Layout measurement deserves the same restraint. If image loading, CMS content or responsive changes affect geometry, refresh when those conditions actually change. Repeated layout reads on every scroll frame are a very different cost from recalculating after a meaningful layout change.
Pinning is a layout decision before it is an animation decision
A surprising amount of scroll complexity disappears when layout takes responsibility for layout.
If the requirement is simply to keep a heading visible while adjacent content moves, position: sticky may already express the relationship. The animation layer can then manage changing visual states instead of reconstructing the entire layout through scroll transforms.
ScrollTrigger's pin becomes useful when the pinned state needs to share a controlled range with a scrubbed sequence. If you use it, keep the pinned frame geometrically stable. GSAP specifically cautions against animating the pinned element itself because transforms can interfere with the measurements used for pinning; animate elements inside the pinned frame instead. GSAP ScrollTrigger pin guidance
Viewport height matters here. A desktop treatment with a large pinned heading, screenshot and several viewport-heights of scroll can become cramped on a short landscape device even when its CSS technically still fits.
If the relationship survives as claim → screenshot → supporting explanation, a stacked mobile version may communicate the same idea with considerably less machinery.
Build the mobile and reduced-motion path before the polish pass
Reduced motion should change the mechanic when the mechanic itself creates substantial spatial movement.
Consider a horizontal showcase where scrolling moves cards sideways while a heading stays fixed. Slowing that movement still asks a motion-sensitive user to experience the same traversal. A more appropriate reduced-motion treatment may present the cards vertically and remove the pin altogether.
Mobile can make the same structural change for a different reason. Native scrolling, short viewports and touch input often make a stacked sequence easier to use than a desktop interaction compressed into a smaller rectangle.
| Condition | Behaviour |
|---|---|
| Desktop / motion allowed | Sticky or pinned frame with local scrubbed sequence |
| Small viewport | Normal vertical flow; no long pin |
| Reduced motion | Readable static states in document order |
| JavaScript unavailable | Core content visible; decorative reveal skipped |
Reduced motion is not a complete accessibility strategy. Keyboard access, focus visibility, semantic order and readable static content still need separate attention. A pinned interaction with controls, for example, must not make keyboard focus disappear behind clipped layers merely because its transform animation has been disabled.
A useful production test is to turn the animation layer off. If the information becomes unreadable, unreachable or permanently transparent, the choreography owns too much of the interface.
A local timeline in practice: Sticky Content Wrapper
A persistent claim beside changing evidence is one of the cases where local sequencing makes sense.
Hyperiux Vault's Sticky Content Wrapper sits in that category. The current Scroll Effects catalogue lists the pattern with GSAP and Lenis and recommends it where one core claim should stay fixed while proof changes beside it.
The implementation choice still belongs to the page using it. A SaaS feature story with three screenshots may justify a long scrubbed range. A denser section may need shorter steps. On mobile, the same content relationship may work better as a vertical sequence without pinning.
Its installation workflow adds selected effect source files directly to the project, where the implementation can be inspected and changed rather than consumed only through a sealed runtime abstraction. Vault installation guide
That makes timing, breakpoints, DOM structure and fallback behaviour project decisions rather than fixed demo assumptions.
Source access becomes most useful after the demo stops matching the real page.
If this claim-and-proof structure fits the page you are building, inspect the Sticky Content Wrapper before recreating the interaction from an empty component.
Before shipping, test whether each animation can fail independently
The architecture is doing its job when one broken interaction does not destabilize everything around it.
| Check | What to verify |
|---|---|
| Static state | Meaningful content remains available without motion |
| Ownership | Each section initializes only the behaviour it owns |
| Cleanup | Observers, triggers and timelines leave when their component does |
| Layout | Images, CMS changes and responsive layout do not leave stale measurements |
| Mobile | Short viewports get a deliberate layout rather than a compressed desktop sequence |
| Reduced motion | Removing movement preserves information and order |
| Accessibility | Keyboard, focus and semantics do not depend on scroll position |
| Performance | Layout measurement and scroll work happen only when necessary |
The next time a page needs another scroll effect, resist asking where it belongs on the master timeline.
Ask what drives it.
A visibility event needs a trigger. Continuous movement needs local progress. Several states that genuinely depend on one another may deserve a timeline, but that timeline can stop at the boundary of the interaction it was created to explain.




