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.

how-to-build-scroll
Hitesh Bhardwaj

Hitesh Bhardwaj

Sr. Developer

Read Time: 7 mins

Category: Engineering

Share this Article:

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

└─ static

The 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 jobExampleSensible starting point
TriggerReveal a card when it enters the viewportIntersectionObserver, Motion whileInView
ProgressParallax, progress bar, continuous transformMotion useScroll, CSS scroll timeline
SequencePinned feature story with coordinated statesScoped 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

ApproachGood fitMeaningful boundary
IntersectionObserverVisibility-driven reveals and state changesNot intended to provide continuous scrubbed progress
CSS scroll timelinesDirect scroll-linked CSS effectsRequires a fallback where support is insufficient
MotionReact-friendly triggered and progress-linked animationComplex multi-stage pinning may benefit from a more explicit timeline
GSAP ScrollTriggerPinning, scrubbing and coupled choreographyMore 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.

ConditionBehaviour
Desktop / motion allowedSticky or pinned frame with local scrubbed sequence
Small viewportNormal vertical flow; no long pin
Reduced motionReadable static states in document order
JavaScript unavailableCore 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.

CheckWhat to verify
Static stateMeaningful content remains available without motion
OwnershipEach section initializes only the behaviour it owns
CleanupObservers, triggers and timelines leave when their component does
LayoutImages, CMS changes and responsive layout do not leave stale measurements
MobileShort viewports get a deliberate layout rather than a compressed desktop sequence
Reduced motionRemoving movement preserves information and order
AccessibilityKeyboard, focus and semantics do not depend on scroll position
PerformanceLayout 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.

tags:ReactScroll AnimationGSAP

Related Blogs

prebuilt-vs-custom
Engineering
September 23, 2026

Prebuilt Interaction Patterns vs Hand-Built Animations in React

Choose between native APIs, animation libraries, and reusable source-first patterns based on how much control, orchestration, and project-specific adaptation the interaction actually requires.

css-animation-featured
Engineering
September 23, 2026

When CSS Animation Is Enough and When React Needs a Library

Learn where CSS ends and animation libraries begin by matching the tool to the interaction- using CSS for visual states and Motion or GSAP when gestures, layout, presence, or complex choreography require more control.

hidden-complexity-of-page-transition
Engineering
September 25, 2026

The Hidden Complexity of Page Transitions in the Next.js App Router

Next.js page transitions look simple until routing, Suspense, layouts and accessibility collide. Learn what App Router transitions actually require in production.

how-to-build-reusable-react-animations
Engineering
September 25, 2026

How to Build Reusable React Animation Components Without Making Every Effect Config-Heavy

Build reusable React animation components without prop explosion. Learn what belongs in props, presets, composition, shared defaults, and source-level edits.

native-scroll-timelines
Engineering
September 25, 2026

Native Scroll Timelines Are Here: Does Every Scroll Effect Still Need JavaScript?

CSS scroll-driven animations can replace JavaScript for many scroll effects, but not all. See where native timelines fit, where JS still earns its place, and how to choose.