Home Recipes Scroll Reveal Scroll Driven

Scroll Reveal

Reveal elements as they enter the viewport using CSS scroll-driven animations with an IntersectionObserver fallback.

Interactive demo

Scroll down ↓

01

Reveal item 1

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

02

Reveal item 2

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

03

Reveal item 3

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

04

Reveal item 4

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

05

Reveal item 5

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

06

Reveal item 6

This element fades up as it enters the viewport. The modern version uses animation-timeline: view(); older browsers fall back to IntersectionObserver.

What it does

Elements fade and slide into view as they enter the viewport, creating a sense of progressive discovery while scrolling.

When to use it

  • Landing page sections that you want to introduce gradually.
  • Lists, cards or galleries where staggered entrance adds rhythm.
  • Long-form content to keep the reader engaged.

When to avoid it

  • Above-the-fold content that must be visible immediately.
  • Critical UI controls that could be missed if hidden initially.
  • Pages where users expect instant full content, such as documentation.
  • When reduced motion is enabled unless content is shown instantly.

How it works

  1. Modern path — use CSS animation-timeline: view() and animation-range to drive the reveal.
  2. Fallback path — if animation-timeline is unsupported, use IntersectionObserver to toggle an is-visible class.
  3. Animate properties — interpolate opacity, translateY and optionally filter blur.
  4. Stagger — delay sibling elements with transition-delay or custom properties.
  5. Reduced motion — show elements at full opacity with no transform.

Source code

HTMLCSSTypeScript
<main class="scroll-container">
  <section class="reveal-section" data-reveal>
    <h2>Section one</h2>
    <p>This element reveals as it enters the viewport.</p>
  </section>
  <section class="reveal-section" data-reveal>
    <h2>Section two</h2>
    <p>Progressive enhancement lets older browsers fall back gracefully.</p>
  </section>
  <section class="reveal-section" data-reveal>
    <h2>Section three</h2>
    <p>CSS scroll-driven animations keep the work off the main thread.</p>
  </section>
</main>
:root {
  color-scheme: dark;
  --bg: #0a0a0b;
  --surface: #16161a;
  --border: rgba(255, 255, 255, 0.1);
  --text: #f4f4f5;
  --muted: #a1a1aa;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.scroll-container {
  display: grid;
  gap: 4rem;
  padding: 80vh 1.5rem 80vh;
}

.reveal-section {
  max-width: 640px;
  margin-inline: auto;
  padding: 2rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 1rem;

  /* Modern path */
  animation: reveal-enter both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}

@keyframes reveal-enter {
  from {
    opacity: 0;
    transform: translateY(30px);
    filter: blur(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

/* Fallback class set by JS when animation-timeline is unsupported */
.reveal-section.is-hidden {
  opacity: 0;
  transform: translateY(30px);
  filter: blur(4px);
  transition: opacity 500ms ease-out,
              transform 500ms ease-out,
              filter 500ms ease-out;
}

.reveal-section.is-visible {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}

@media (prefers-reduced-motion: reduce) {
  .reveal-section {
    animation: none;
    opacity: 1;
    transform: none;
    filter: none;
  }
}
function prefersReducedMotion(): boolean {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function supportsScrollTimeline(): boolean {
  return CSS.supports("animation-timeline", "view()");
}

function initScrollReveal(): void {
  if (prefersReducedMotion()) return;
  if (supportsScrollTimeline()) return;

  const items = document.querySelectorAll<HTMLElement>("[data-reveal]");
  items.forEach((item) => item.classList.add("is-hidden"));

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add("is-visible");
          observer.unobserve(entry.target);
        }
      });
    },
    { threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
  );

  items.forEach((item) => observer.observe(item));
}

initScrollReveal();

export {};

Implementation recipe

Scroll Reveal Implementation Recipe

Goal

Reveal elements as they scroll into the viewport using CSS scroll-driven animations, with a JavaScript fallback for older browsers.

Requirements

  • Elements hidden or offset before entering the viewport.
  • Smooth entrance animation driven by scroll position.
  • Fallback for browsers without animation-timeline: view().
  • Reduced-motion support.

Files to create

  • index.html
  • styles.css
  • scroll-reveal.ts

Step 1: Markup

Add a data-reveal attribute to each element you want to animate.

Step 2: Modern CSS animation

Use animation-timeline: view() to bind the animation to the element's visibility in the viewport. Define an animation-range to control when the animation starts and ends.

.reveal-section {
  animation: reveal-enter both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}

@keyframes reveal-enter {
  from {
    opacity: 0;
    transform: translateY(30px);
    filter: blur(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

Step 3: Fallback CSS

Add classes that will be toggled by IntersectionObserver when scroll timelines are unavailable.

.reveal-section.is-hidden {
  opacity: 0;
  transform: translateY(30px);
  filter: blur(4px);
  transition: opacity 500ms ease-out,
              transform 500ms ease-out,
              filter 500ms ease-out;
}

.reveal-section.is-visible {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}

Step 4: Feature detection

Check support with CSS.supports("animation-timeline", "view()"). If supported, do nothing; the CSS handles everything.

Step 5: IntersectionObserver fallback

If unsupported, add the hidden class to every item and observe them. When an item intersects, add is-visible and stop observing it.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add("is-visible");
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.15, rootMargin: "0px 0px -40px 0px" });

Step 6: Reduced motion

If prefers-reduced-motion is active, skip the observer and make elements visible immediately.

Common pitfalls

  • Hiding focusable elements with opacity: 0 can make them unreachable.
  • Blur filters can be expensive on large areas.
  • Forgetting the fallback leaves older browsers with invisible content.

Verification checklist

  • Elements reveal smoothly while scrolling.
  • The effect works in browsers that support animation-timeline: view().
  • Unsupported browsers use the IntersectionObserver fallback.
  • Reduced motion shows content immediately.
  • Focusable elements become visible quickly enough to be usable.
Accessibility notes
  • Ensure content is readable and interactive even before the reveal completes.
  • Do not hide focusable elements with opacity: 0 for long; they should be visible quickly.
  • Respect prefers-reduced-motion by removing the reveal animation entirely.
  • Keep the DOM order logical so screen readers announce content in the right sequence.
Performance notes
  • Prefer animation-timeline over JavaScript scroll listeners.
  • Use IntersectionObserver instead of scroll event handlers for the fallback.
  • Animate only transform and opacity to stay on the compositor.
  • Avoid blur filters on large areas; they can be GPU expensive.
  • Throttle any remaining scroll work with requestAnimationFrame.
Browser support & fallbacks
API / Feature Support Fallback strategy
animation-timeline: view() Chrome 115+, Edge 115+, Safari 18+ IntersectionObserver toggles a visible class.
IntersectionObserver All modern browsers Show all elements immediately.
prefers-reduced-motion All modern browsers Instant visibility with no motion.