Keyframe
Keyframe · The log

Scroll Animation in CSS: Reveal on Scroll the Modern Way

July 24, 2026 · Uncategorized

Elements that fade or slide in as you scroll to them used to require a chunk of JavaScript and a scroll listener firing sixty times a second. That era is ending. Browsers now ship scroll-driven animations natively, and for the fallback there is IntersectionObserver, which is a far calmer tool than the old approach. Here is how to do it both ways, and when each is right.

The native way, no JavaScript at all

Scroll-driven animations tie an animation’s progress to the scroll position instead of a clock. The animation-timeline: view() line tells the browser to run the keyframes based on where the element sits in the viewport.

.reveal {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

The animation-range is the interesting part. It says the animation should start when the element enters the viewport and finish by the time it is 40 percent of the way in, so the reveal completes before the reader is really looking at it. No listeners, no libraries, and the browser runs it on the compositor where it stays smooth.

The catch is support. This works in current Chrome and Edge and is arriving elsewhere, so you treat it as an enhancement rather than the whole plan.

The IntersectionObserver fallback

For everything else, IntersectionObserver watches elements and tells you the moment one crosses into view. You add a class, CSS handles the motion. The observer does the watching efficiently, off the main thread, which is why it replaced scroll listeners.

const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add("in");
      io.unobserve(entry.target);
    }
  });
}, { threshold: 0.2 });

document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.5s ease, transform 0.5s ease;
}

.reveal.in {
  opacity: 1;
  transform: translateY(0);
}

Calling unobserve once an element has appeared is the part people skip. Without it, the observer keeps tracking things that already finished, which is wasted work. The threshold: 0.2 means the reveal fires when a fifth of the element is visible, which usually feels right.

Do not trap the content behind the effect

There is a real danger with scroll reveals. If the starting state is opacity: 0 and the script fails, or the browser never fires the class, the content stays invisible forever. Now your animation has hidden your article. Anyone with JavaScript disabled, or a slow connection that timed out, sees a blank column.

Guard against it two ways. Add a no-js style path so content shows when scripts do not run, and always honor reduced motion. For readers who ask for less movement, skip the animation and show everything in place from the start.

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

Scroll animation is seasoning. A little wakes a page up, a lot turns reading into a fight against motion. The reduced-motion rule above is expanded in accessible animation, and if you are deciding between a transition and a keyframe for the reveal, the transition versus animation comparison helps. Everything sits under the pillar on CSS animation.

← Back to the log