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.
| Approach | Best for | Support |
|---|---|---|
| CSS animation-timeline | Progress bars, fades and parallax tied to scroll position | Chromium 115+, Safari 26; check caniuse for Firefox |
| IntersectionObserver | One-off reveals and lazy starts | All current major browsers |
| GSAP ScrollTrigger | Pinned scenes, scrubbed timelines, complex sequences | Wherever 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.
Each frame scrolls on its own. In a browser without animation-timeline, the @supports guard leaves everything visible and still.
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>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>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>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>The live demo near the top of this page runs this same pattern in React. These versions work in any framework or none.
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 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 });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));Code only. ScrollTrigger is a GSAP plugin; both are free to use under GSAP's own licence, which you should read before shipping.
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" });
});
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.
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.
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.
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.
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.
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.
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.
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.