This article covers how to add scroll-driven animation to images.
The short answer: check whether CSS alone will do it first. animation-timeline: view() gives you scroll-driven animation with no library at all. Only reach for a library when you need genuine WebGL distortion.
Sponsored
Scroll-driven animation in CSS
animation-timeline: view() advances an animation while the element crosses the viewport. No JavaScript required.
An ordinary animation progresses with time. Setting animation-timeline makes it progress with scroll position instead, and scrolling back rewinds it.
@keyframes reveal {
from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.fade-img {
animation: reveal linear both;
/* progresses as the element enters the viewport */
animation-timeline: view();
/* the range it covers */
animation-range: entry 0% cover 40%;
}
animation-range defines the start and end points. entry 0% is the moment the element begins entering from the bottom; cover 40% is when it has crossed 40% of the viewport.
A curtain-style reveal is also just CSS, using clip-path.
@keyframes curtain-open {
from { clip-path: inset(0 50% 0 50%); }
to { clip-path: inset(0 0 0 0); }
}
.curtain-img {
animation: curtain-open linear both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
inset(0 50% 0 50%) hides 50% from each side. Animating that to zero reveals the image outward from the centre.
Handling browsers without support
Chrome and Edge 115 onwards and Safari 18 support it. Firefox still has it behind a flag as of September 2026, so branch with @supports.
/* visible by default for unsupported browsers */
.fade-img {
opacity: 1;
}
@supports (animation-timeline: view()) {
.fade-img {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
Start from the visible state so images never stay hidden. Leaving opacity: 0 as the default means the image never appears at all where the animation is unsupported.
Respect reduced-motion preferences too:
@media (prefers-reduced-motion: reduce) {
.fade-img {
animation: none;
opacity: 1;
}
}
Controlling it with JavaScript
If the animation should run once and stay, use Intersection Observer.
animation-timeline is tied to scroll position, so scrolling back up reverses it. For a one-time reveal, just add a class.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
// one-time only, so stop observing
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.fade-img').forEach((el) => observer.observe(el));
.fade-img {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.6s, transform 0.6s;
}
.fade-img.is-visible {
opacity: 1;
transform: none;
}
Calling unobserve() is the important part. Without it, the check keeps running every time the element crosses the viewport.
Sponsored
Distorting images with WebGL
For rippling, warping or pointer-reactive distortion, use curtains.js. It turns HTML image elements into WebGL textured planes.
curtains.js is a vanilla JavaScript library by Martin Laxenaire, still receiving updates as of 2025. It converts <img> and <video> elements into WebGL planes you can manipulate with shaders.
npm install curtainsjs
The shape of the work is:
- Add an element to host the canvas
- Place the images you want to animate inside it
- Initialise
Curtainsand register the image containers as planes - Write vertex and fragment shaders to define the effect
Writing shaders is a real step up in complexity from CSS. For a fade or a curtain reveal, the CSS above is enough.
Setup instructions and examples are on the curtains.js site. The demos are worth looking at first to judge whether the effect you want justifies the library.
Choosing an approach
Pick based on how complex the effect is. Every library you add makes the page heavier.
▼Which to use
| Goal | Approach | Extra payload |
|---|---|---|
| Fade in on scroll | animation-timeline: view() |
None |
| Curtain-style reveal | clip-path plus the above |
None |
| Run once and stay | Intersection Observer | None (your own JS) |
| Warp or ripple the image | curtains.js | A library |
Working down that list only as far as you need keeps the page lightest. Starting with a library means shipping capabilities you never use.
The same property drives 5 ways to change colour on scroll. For pointer-reactive effects, see building a mouse stalker that trails the cursor.
A correction to this article
This article originally recommended a library called curtain.js. That was wrong on several counts, and it has been rewritten in September 2026.
- The curtain.js it named (by Victa) is a jQuery plugin for full-screen unfolding panels, not for images
- That plugin’s GitHub page states “PROJECT IS NOT MAINTAINED”
- The
new Curtain()syntax and options shown did not exist - The official URL given no longer resolves
A similarly named library, curtains.js (with an s), is a different project and is actively maintained. That one does match the goal of animating images. The corrected information follows.