Scroll Animations: Four Effects, Three Ways

Scroll animations play effects as content enters view or as the page scrolls. Modern CSS links keyframes to scrolling with animation-timeline: view() or scroll(), no script needed where supported. IntersectionObserver in JavaScript triggers reveal classes everywhere, and GSAP ScrollTrigger handles pinning and scrubbed timelines. Always honour prefers-reduced-motion.

Pick an approach
ApproachBest forSupport
CSS animation-timelineProgress bars, fades and parallax tied to scroll positionChromium 115+, Safari 26; check caniuse for Firefox
IntersectionObserverOne-off reveals and lazy startsAll current major browsers
GSAP ScrollTriggerPinned scenes, scrubbed timelines, complex sequencesWherever the library runs

Updated

Four effects people ask for most, reveal on scroll, a reading progress bar, parallax and a sticky story, are each shown in pure CSS, in plain JavaScript with IntersectionObserver and in GSAP ScrollTrigger. The CSS versions run live in scrollable frames, and the observer version runs as a working demo right on this page.

Pure CSS: scroll-driven animations (live)

Each frame scrolls on its own. In a browser without animation-timeline, the @supports guard leaves everything visible and still.

Reveal on scroll with view()

Each card fades and rises as it enters the viewport. animation-range limits the effect to the entry phase.

<style>
.card { margin: 0 0 24px; padding: 24px; border-radius: 12px; background: #e0f2fe; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
    @keyframes reveal { from { opacity: 0; transform: translateY(40px); } }
  }
}
</style>
<p style="color:#64748b">Scroll inside this box.</p>
<div style="height:220px"></div>
<div class="card">First card</div><div class="card">Second card</div><div class="card">Third card</div><div class="card">Fourth card</div>

Reading progress bar with scroll()

scroll(root) follows the document scroller. The bar scales from 0 to full width with no listener at all.

<style>
.progress { position: fixed; inset: 0 0 auto; height: 6px; background: #4F98FB; transform-origin: left; transform: scaleX(0); }
@supports (animation-timeline: scroll()) {
  .progress { animation: grow linear both; animation-timeline: scroll(root); }
  @keyframes grow { to { transform: scaleX(1); } }
}
@supports not (animation-timeline: scroll()) { .progress { display: none; } }
</style>
<div class="progress"></div>
<p style="color:#64748b">Scroll inside this box.</p>
<div style="height:900px;background:linear-gradient(#fff,#f1f5f9)"></div>

Parallax layer with view()

The inner layer moves slower than the page while its box crosses the viewport. Turned off for reduced motion.

<style>
.hero { position: relative; height: 220px; overflow: hidden; border-radius: 12px; margin: 200px 0; }
.layer { position: absolute; inset: -60px 0; background: repeating-linear-gradient(45deg,#1e3a8a 0 20px,#1d4ed8 20px 40px); }
.title { position: relative; color: #fff; font: 700 28px system-ui; padding: 80px 20px; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .layer { animation: drift linear both; animation-timeline: view(); }
    @keyframes drift { from { transform: translateY(-50px); } to { transform: translateY(50px); } }
  }
}
</style>
<p style="color:#64748b">Scroll inside this box.</p>
<div class="hero"><div class="layer"></div><p class="title">Parallax heading</p></div>

Sticky story with view()

The picture stays pinned with position: sticky while each step brightens as it crosses the middle of the frame.

<style>
.story { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.pin { position: sticky; top: 16px; height: 160px; border-radius: 12px; background: #fde68a; display: grid; place-items: center; font: 600 18px system-ui; }
.step { height: 200px; padding: 12px; border-left: 4px solid #f59e0b; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .step { animation: focus linear both; animation-timeline: view(); animation-range: cover 25% cover 75%; }
    @keyframes focus { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
  }
}
</style>
<p style="color:#64748b">Scroll inside this box.</p>
<div class="story"><div class="pin">Pinned picture</div>
<div><div class="step">1. Pick a design</div><div class="step">2. Add your words</div><div class="step">3. Publish</div></div></div>
<div style="height:120px"></div>

Plain JavaScript with IntersectionObserver

The live demo near the top of this page runs this same pattern in React. These versions work in any framework or none.

Reveal on scroll

Cards start hidden only when the script has run, by adding a class to html first, so a failed script leaves them visible.

document.documentElement.classList.add("js");
// CSS: .js .reveal { opacity: 0; transform: translateY(24px); transition: opacity .6s, transform .6s; }
//      .js .reveal.is-in { opacity: 1; transform: none; }
//      @media (prefers-reduced-motion: reduce) { .js .reveal { transform: none; transition: opacity .2s; } }
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); }
  }
}, { rootMargin: "0px 0px -10% 0px", threshold: 0.1 });
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));

Progress bar

Progress needs the exact scroll position, so this one uses a passive scroll listener with one frame of throttling.

const bar = document.querySelector(".progress");
let queued = false;
addEventListener("scroll", () => {
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => {
    const max = document.documentElement.scrollHeight - innerHeight;
    bar.style.transform = "scaleX(" + (max > 0 ? scrollY / max : 0) + ")";
    queued = false;
  });
}, { passive: true });

Sticky story step tracking

A thin observation band in the middle of the screen marks whichever step is crossing it as active.

const pic = document.querySelector(".pin");
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) pic.dataset.step = e.target.dataset.step; // CSS swaps the picture per data-step
  }
}, { rootMargin: "-50% 0px -50% 0px" });
document.querySelectorAll(".step").forEach((s) => io.observe(s));

GSAP ScrollTrigger

Code only. ScrollTrigger is a GSAP plugin; both are free to use under GSAP's own licence, which you should read before shipping.

The same four effects

gsap.matchMedia runs the setup only when the reader has not asked for reduced motion, and reverts it if the setting changes.

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
  // 1. Reveal
  gsap.utils.toArray(".reveal").forEach((el) => {
    gsap.from(el, { opacity: 0, y: 40, duration: 0.6, scrollTrigger: { trigger: el, start: "top 85%", once: true } });
  });
  // 2. Progress bar, scrubbed to the whole page
  gsap.to(".progress", { scaleX: 1, ease: "none", transformOrigin: "left", scrollTrigger: { start: 0, end: "max", scrub: true } });
  // 3. Parallax
  gsap.fromTo(".layer", { yPercent: -15 }, { yPercent: 15, ease: "none", scrollTrigger: { trigger: ".hero", scrub: true } });
  // 4. Sticky story: pin the picture while the steps scroll past
  ScrollTrigger.create({ trigger: ".story", start: "top top", end: "bottom bottom", pin: ".pin" });
});

Performance

Animate transform and opacity. Moving elements with top, left, margin or height forces layout on every frame and stutters on slower phones. Scroll-driven CSS animations of transform and opacity can run on the compositor, away from the main thread.

Avoid reading layout values such as getBoundingClientRect inside a scroll handler. If you must, batch the reads in requestAnimationFrame and write styles afterwards.

Motion sensitivity

Parallax and large zooms tied to scrolling are a known trigger for vestibular discomfort, because the motion does not match what the reader's hand is doing. WCAG 2.3.3 Animation from Interactions asks that such motion can be turned off, and the reduced-motion media query is the simplest way to do it.

How it works

  1. Scroll inside the live demo box on this page to watch IntersectionObserver reveal cards, fill a progress bar and step through a sticky story.
  2. Scroll inside each CSS frame below. Those run with no script at all, using scroll-driven animations where the browser supports them.
  3. Compare the JavaScript and GSAP code for the same effects and copy the one that fits your stack.
  4. Keep the reduced-motion rules in whatever you copy.

Tips

Frequently asked questions

What is animation-timeline in CSS?

It ties an animation's progress to scrolling instead of time. scroll() follows how far a scroll container has scrolled, from top to bottom. view() follows one element's journey through the visible area, so the keyframes run as it enters and leaves.

Which browsers support CSS scroll-driven animations?

Chrome and Edge since version 115, and Safari since version 26. Firefox has had the feature behind a preference rather than on by default, so check caniuse before relying on it, and always provide a fallback.

Is IntersectionObserver better than a scroll event listener?

For knowing when an element enters or leaves view, yes. The browser computes intersections asynchronously and calls you only on changes, while a scroll listener runs many times a second and tempts you into layout reads that cause jank.

Do I need GSAP for scroll animations?

Not for simple reveals or a progress bar. GSAP ScrollTrigger earns its place for pinned sections, scrubbed multi-step timelines, snapping and effects that must behave the same in every browser today.

How should scroll animations handle reduced motion?

Inside a prefers-reduced-motion: reduce media query, show content in its final state and drop parallax and large movement. In JavaScript, check window.matchMedia for the same query before creating animations. Gentle opacity fades are usually acceptable.

Related: SVG animation examples, CSS transitions, CSS position and sticky, CSS animation generator, Modern CSS features.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap