Browse by section

Web Design 日本語

Scroll-Driven Image Animation With CSS and curtains.js

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:

  1. Add an element to host the canvas
  2. Place the images you want to animate inside it
  3. Initialise Curtains and register the image containers as planes
  4. 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.