Scroll Reveal Patterns

Scroll reveal is an animation triggered when an element enters the viewport. Done right: it makes the page feel “alive”, with content appearing right as the reader looks at it. Done wrong: the user scrolls down and sees… emptiness, has to wait for the animation, and loses the content.

Scroll inside the box to watch each item fade up into view

↓ Scroll inside this box
01
02
03
04
05

Intersection Observer

The foundation of scroll reveal is IntersectionObserver — a native API that tracks when an element intersects the viewport.

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      entry.target.classList.add('revealed');
      observer.unobserve(entry.target); // animate only once
    });
  },
  { threshold: 0.15 } // trigger when 15% of the element is visible
);

document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}

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

Why unobserve after triggering: scroll reveal should run only once. If the animation resets every time the user scrolls up and down, it’s distracting and unnatural.

Pattern 1: Simple fade-up

Suitable for most content blocks (heading, paragraph, image).

.fade-up {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}

.fade-up.revealed {
  opacity: 1;
  transform: translateY(0);
}

Translate only needs 16–24px to create a directional cue. No need for 60px or 100px — that would look like the element “falling out of the sky”.

Pattern 2: Stagger group

Multiple elements in a container appear one after another. See the details in Stagger Animation.

const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;
    const items = entry.target.querySelectorAll('.stagger-item');
    items.forEach((el, i) => {
      el.style.transitionDelay = `${i * 60}ms`;
      el.classList.add('revealed');
    });
    observer.unobserve(entry.target);
  });
}, { threshold: 0.1 });

Observe the container, not each item: if you observe each item individually, items that aren’t visible will trigger randomly as you scroll. The container is the semantic unit.

Pattern 3: Scroll-linked (parallax)

Elements move at different speeds based on scroll position. Unlike reveal — this is a continuous animation.

// Scroll-linked with a CSS custom property
const section = document.querySelector('.parallax-section');
const img = section.querySelector('.parallax-img');

window.addEventListener('scroll', () => {
  const rect = section.getBoundingClientRect();
  const progress = 1 - (rect.bottom / (rect.height + window.innerHeight));
  img.style.setProperty('--parallax-y', `${progress * 40}px`);
}, { passive: true });
.parallax-img {
  transform: translateY(var(--parallax-y, 0));
  will-change: transform;
}

Use { passive: true } for the scroll listener so it doesn’t block rendering. And will-change: transform to get a GPU composite layer.

Use sparingly: parallax looks nice but costs performance. On mobile, consider disabling it entirely.

Threshold and rootMargin

threshold is the fraction of the element that must be visible to trigger (0 = 1 pixel, 1 = the entire element).

rootMargin expands or shrinks the trigger area (measured from the edge of the viewport).

// Trigger 100px earlier, before the element enters the viewport
const observer = new IntersectionObserver(callback, {
  rootMargin: '0px 0px -100px 0px', // negative bottom margin
  threshold: 0
});

rootMargin: '0px 0px -100px 0px' means it triggers when the element is still 100px from the bottom of the viewport — enough time for the animation to start before the user actually looks at it.

Common mistakes

Above-the-fold content is hidden: content that’s visible immediately on load isn’t revealed right away — the user sees a blank page before the animation runs. Check: all content above the fold must be visible immediately.

// Solution: only observe elements below the fold
const foldLine = window.innerHeight;
document.querySelectorAll('.reveal').forEach(el => {
  if (el.getBoundingClientRect().top < foldLine) {
    el.classList.add('revealed'); // show immediately
  } else {
    observer.observe(el);
  }
});

Too many elements reveal at once: if 20 elements all trigger at the same threshold, they appear almost simultaneously — there’s no cascade. Increase the threshold or use stagger.

Not handling prefers-reduced-motion:

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

See also: Stagger Animation, Cubic Bezier In Depth.