Guides · 6 min read · Updated
How do you animate a website on scroll?
Use the lightest tool that does the job: CSS scroll-driven animations for simple reveals and progress effects, IntersectionObserver to fire a one-time reveal, and GSAP ScrollTrigger when you need pinned or scrubbed sequences. Animate only transform and opacity, honor the reduced motion setting, and leave native scrolling alone unless the design truly needs a smoothed virtual scroll.
Choose the tool by the job
Most scroll animation falls into four jobs, and each has a tool that fits it best. Picking by job keeps the page light, because the cheapest tool that works is also the one least likely to drop frames on a phone.
| Job | Best tool | Why |
|---|---|---|
| Fade or slide an element in once | IntersectionObserver plus a CSS class | One callback per element, then it stops observing |
| Tie an effect to scroll position (progress bar, parallax, reveal that reverses) | CSS scroll-driven animations | No JavaScript on the scroll path, and the browser runs the timeline |
| Pin a section and play a sequence across it | GSAP ScrollTrigger | Pinning, timelines and scrubbing are built in and tested across browsers |
| Make the whole page feel weighty and smooth | A smoothed value you animate with | Smooth the number your visuals read, not the scrollbar itself |
You can mix them. A page might use CSS for its reveals, one GSAP pinned scene for the product story, and a small smoothed value that drives a WebGL background. The rule is that each effect gets exactly one owner, so two systems never fight over the same element's transform.
CSS scroll-driven animations
CSS scroll-driven animations let a normal @keyframes animation run on scroll progress instead of on a clock. You attach a timeline with animation-timeline: view() tracks one element as it crosses the viewport, and scroll() tracks how far a scroller has scrolled. animation-range chooses which part of that journey plays the animation.
Two details trip people up. First, the animation shorthand resets animation-timeline, so write the timeline after the shorthand. Second, browser support is still uneven, so wrap the rules in @supports and let unsupported browsers show the finished state instead of a hidden one.
@keyframes rise {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: none; }
}
@supports (animation-timeline: view()) {
.reveal {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}The same idea gives you a reading progress bar. The bar is hidden by default and only appears where the timeline works, so nobody sees a bar stuck at full width.
.progress { display: none; }
@supports (animation-timeline: scroll()) {
.progress {
display: block;
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: currentColor;
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root block);
}
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}Use CSS when the effect is a pure function of position: the element at 30 percent of its journey always looks the same. Reach for JavaScript when it depends on state, such as an animation that must finish even if the user stops scrolling.
IntersectionObserver for one-time reveals
IntersectionObserver tells you when an element enters or leaves a viewport or a container, without you polling its position on every scroll event. Callbacks arrive asynchronously, so the browser can batch the work. It is the right tool for the most common scroll effect on the web: an element fades in the first time it is seen.
Unobserve the element after it reveals. A long page with 200 observed elements that never stop reporting is wasted work. Also gate the hidden state behind a class set by JavaScript, so a visitor with scripts blocked still sees your content.
document.documentElement.classList.add("js");
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-in");
io.unobserve(entry.target);
}
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.15 }
);
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));.js [data-reveal] {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.js [data-reveal].is-in {
opacity: 1;
transform: none;
}The rootMargin of -10 percent at the bottom means an element reveals when it is a little way into the screen, not the instant its top edge appears. That small offset is the difference between a reveal people notice and one that happens below the fold where nobody sees it.
GSAP ScrollTrigger for pinned and scrubbed scenes
ScrollTrigger connects animations to scroll position. It can start an animation when an element reaches a point, scrub a timeline so it follows the scrollbar, and pin an element in place while a sequence plays. A number for scrub, such as scrub: 1, adds smoothing so the animation takes about a second to catch up with the scrollbar.
ScrollTrigger is a plugin, so register it once with gsap.registerPlugin(ScrollTrigger). Wrap scenes in gsap.matchMedia() so they only run when the visitor has not asked for reduced motion, and so the animation is cleaned up when the condition changes.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".story",
start: "top top",
end: "+=2000",
pin: true,
scrub: 1,
},
});
tl.from(".story h2", { autoAlpha: 0, y: 40 })
.to(".story .card", { xPercent: -100, ease: "none" }, ">");
});Pinning adds padding after the pinned section so the content below catches up, which changes your page height. Create pinned scenes in the order they appear on the page, and call ScrollTrigger.refresh() after late-loading images change layout. Use ease: "none" on scrubbed tweens: the scrollbar already supplies the easing, and adding another curve makes the motion feel mushy.
A smoothed virtual scroll, done carefully
Virtual or smooth scroll libraries intercept the wheel and animate the page toward the target, which gives the glide you see on award-style sites. Many AnimatedWebs templates list Lenis in their stack for this reason. The cost is real: you take over native behavior such as keyboard scrolling, find-in-page, and anchor links, and you must test all of them.
A safer pattern keeps native scrolling and smooths only the number your visuals read. Scroll position is the input, a lerped value is the output, and your canvas, transforms or CSS variables use the lerped value. The page still scrolls exactly as the browser intends, but the motion on it eases.
let target = 0;
let current = 0;
let last = performance.now();
addEventListener(
"scroll",
() => {
const max = document.documentElement.scrollHeight - innerHeight;
target = max > 0 ? scrollY / max : 0;
},
{ passive: true }
);
function tick(now) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
current += (target - current) * (1 - Math.exp(-dt * 6));
document.documentElement.style.setProperty("--p", current.toFixed(4));
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);Using the frame time dt in the easing formula keeps the glide the same on a 60 Hz phone and a 144 Hz monitor. A fixed factor per frame would feel twice as snappy on the faster screen.
Reduced motion is part of the build
The prefers-reduced-motion media query reports whether the visitor asked their system to remove or reduce non-essential motion, which matters for people who get dizzy or nauseous from parallax and large movement. The value reduce means they did. Treat it as a design state, not an afterthought: the page must still be complete, readable and navigable with no motion at all.
@media (prefers-reduced-motion: reduce) {
.js [data-reveal] {
opacity: 1;
transform: none;
transition: none;
}
.reveal { animation: none; }
}In JavaScript, read matchMedia("(prefers-reduced-motion: reduce)").matches once at start and listen for changes. Replace big movement with a short fade or nothing, keep content visible, and stop any autoplaying loops. Scrubbed video and WebGL scenes should fall back to a still frame.
What to animate, and what not to
Browsers can animate transform and opacity without redoing layout or paint on every frame, which is why they are the safe pair. Everything else costs more.
- Animate: transform (translate, scale, rotate) and opacity, plus filter on small elements and clip-path for reveals.
- Avoid on scroll: width, height, top, left, margin and font-size. They force layout, and layout on every scroll frame is the classic cause of jank.
- Avoid: animating box-shadow or large blurs on big areas. Fade a pre-rendered shadow layer instead.
- Do not hide primary content behind animation. The headline and the call to action should be readable without waiting for a trigger.
- Do not hijack the scrollbar. Scroll-jacking that fights the wheel loses visitors faster than a plain page does.
- Do not animate everything. One hero moment and a few reveals read as designed; forty moving things read as noise.
Add will-change: transform only to elements that are about to animate, and remove it after. Leaving it on a hundred elements wastes graphics memory, which phones have less of.
A short pre-launch check
- 1Turn on reduced motion in your system settings and reload. The page should be complete and calm.
- 2Scroll with the keyboard, jump to an anchor link and use find-in-page. Nothing should break.
- 3Open the Performance panel, record a scroll, and confirm there are no long tasks during it.
- 4Test on a mid-range phone, not only on your laptop.
- 5Disable JavaScript once. Content should still be visible.
If you would rather start from a working page than build these effects yourself, the templates below ship the techniques in this guide. Every one has a live demo, so you can scroll it before you commit to a build.





