UI/UX Atlas
Motion & Animation Intermediate

Scroll-Driven & Viewport-Triggered Animations

Learn how to make scrolling a first-class animation timeline — revealing content with purpose, synchronizing effects to progress, and keeping motion accessible.

7 min read

The full lesson

Scroll is the most universal gesture on the web. For years, animating in response to scroll was painful — JavaScript had to poll window.scrollY on every frame, fighting the browser’s scroll thread and causing jank. Two modern APIs fixed this: the Intersection Observer API for viewport-triggered animations, and the CSS Scroll-Driven Animations spec for animations tied directly to scroll position. Together they give you a precise, performant, and accessible toolkit for scroll-based motion.

Getting this right matters beyond aesthetics. Content that appears as the user reaches it — rather than all at once — reduces cognitive load and creates a sense of narrative flow. Done poorly, scroll animations delay content, trap users in forced tours, or cause motion sickness. This lesson draws the line between purposeful scroll motion and decorative friction.

Two Distinct Patterns (and When to Use Each)

Scroll animation splits into two fundamentally different models. Mixing them up leads to the wrong tool for the job.

Viewport-triggered animation fires once when an element enters (or exits) the visible area. The animation plays forward and stops — it is not linked to scroll position. A feature card fading in as you scroll past the fold is viewport-triggered. It plays the same way no matter how fast you scroll.

Scroll-driven animation ties an animation’s progress directly to scroll distance. At 0% scroll the animation is at 0%; at 50% scroll it is at 50%. A reading-progress bar filling across the top of an article is scroll-driven. So is a parallax hero image, or a sticky section where illustrations transform as you move through a narrative.

PatternTriggerProgress tied to scroll?Best for
Viewport-triggeredElement enters viewportNo — plays onceContent reveal, staggered lists, entrance animations
Scroll-drivenScroll position changesYes — continuous syncProgress bars, parallax, sticky narratives, scrubbed timelines

Viewport-Triggered Animations with Intersection Observer

The Intersection Observer API lets you register a callback that fires when an element crosses a visibility threshold in the viewport. It runs off the main thread and needs no scroll event listeners.

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

document.querySelectorAll('[data-reveal]').forEach((el) => observer.observe(el));

The CSS handles the animation itself:

[data-reveal] {
  opacity: 0;
  translate: 0 24px;
  transition:
    opacity 400ms cubic-bezier(0, 0, 0.3, 1),
    translate 400ms cubic-bezier(0, 0, 0.3, 1);
}

[data-reveal].is-visible {
  opacity: 1;
  translate: 0 0;
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal] {
    opacity: 0;
    translate: none;
    transition: opacity 200ms linear;
  }
}

Notice three things in this pattern:

  • Only opacity and translate are animated. Both are compositor-only properties (shorthands for transform), so they never trigger a layout recalculation.
  • observer.unobserve() is called after the first trigger, so the animation does not re-fire when the user scrolls back. For most content-reveal scenarios, this is the right behavior.
  • The prefers-reduced-motion block replaces the translate with a fade-only transition. The state change is still communicated — just without spatial motion.

Staggering Entrances

When a list of cards reveals together, stagger each element’s delay slightly so they enter one after another. This creates a wave-like choreography that feels organized, not chaotic.

[data-reveal]:nth-child(1) { transition-delay: 0ms; }
[data-reveal]:nth-child(2) { transition-delay: 60ms; }
[data-reveal]:nth-child(3) { transition-delay: 120ms; }
[data-reveal]:nth-child(4) { transition-delay: 180ms; }

Keep stagger offsets small — 50–80 ms between items. Larger offsets make the last item feel abandoned and slow down perceived page readiness. For long dynamic lists, calculate the delay in JavaScript: delay = index * 60.

CSS Scroll-Driven Animations

The CSS Scroll-Driven Animations spec (Baseline 2024, now supported in Chrome, Edge, Firefox, and Safari Technology Preview as of 2026) introduces two timeline types:

  • scroll() — links animation progress to the root or a named scroll container
  • view() — links animation progress to how much of the element has passed through the viewport

Reading Progress Bar

The classic scroll-driven example is a progress bar that fills as the user scrolls through an article:

@keyframes progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: var(--color-accent);
  transform-origin: left center;
  animation: progress linear both;
  animation-timeline: scroll(root block);
}

No JavaScript needed. The browser maps scroll position to animation progress natively, on the compositor thread, at 60+ fps.

View-Linked Entrance Animation

The view() timeline triggers relative to an element’s own position in the viewport. This is useful for animating each item as it enters:

@keyframes fade-up {
  from {
    opacity: 0;
    translate: 0 32px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.card {
  animation: fade-up ease-out both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

animation-range: entry 0% entry 40% means: play the full animation while the element transitions from 0% entered to 40% entered in the viewport. The result is a crisp fade-up that completes well before the card is fully visible.

Named Scroll Timelines

For complex layouts with multiple scroll containers, name the timeline explicitly:

.scroll-container {
  overflow-y: scroll;
  scroll-timeline-name: --feature-scroll;
  scroll-timeline-axis: block;
}

.sticky-illustration {
  animation: morph linear both;
  animation-timeline: --feature-scroll;
}

Do

Use CSS Scroll-Driven Animations for reading progress bars, sticky section illustrations, and parallax-lite effects where the animation must stay in precise sync with scroll position. Use animation-range to control exactly when in the scroll journey the animation plays. Always test with prefers-reduced-motion.

Don't

Do not use scroll-driven animation for primary content reveal — content that is invisible until scrolled to is invisible content. Do not animate layout properties (width, height, margin, padding) on scroll — they cause layout thrashing every frame. Avoid parallax ratios above 0.3 on mobile; the decoupled motion triggers vestibular discomfort in a significant portion of users.

Performance Considerations

The core performance rule for scroll animation is the same as for all motion: only animate compositor-only properties (transform and opacity). Anything else triggers layout or paint on every scroll tick, making jank near-certain on mid-range devices.

Here are additional rules specific to scroll animation:

Avoid will-change on everything. A common mistake is applying will-change: transform to every animated element to “promote it to its own layer.” Layer promotion consumes GPU memory. Use will-change only on elements that are actively animating, and remove it once the animation completes.

Use contain: layout on scroll-animated sections that are visually self-contained. This tells the browser that layout changes inside the element do not affect elements outside it, which reduces recalculation scope.

Debounce fallback JavaScript. If you support browsers without native Scroll-Driven Animations via a polyfill, make sure scroll handlers are either passive (addEventListener('scroll', fn, { passive: true })) or throttled with requestAnimationFrame. Non-passive scroll listeners block the browser’s scroll thread and are a primary cause of scroll jank.

Avoid animating too many elements at once. On a page with 40 cards each using view() timelines, the browser tracks 40 scroll observations simultaneously. Beyond roughly 20–30 concurrent scroll-driven animations on a single page, profile in DevTools to confirm the compositor overhead is acceptable.

Accessibility: Reduced Motion in Scroll Contexts

prefers-reduced-motion: reduce is especially important for scroll-driven animations because they can run continuously while the user scrolls. If you ignore this preference, users are exposed to sustained vestibular-triggering motion with no way to escape it.

The recommended approach is to disable scroll-driven animation entirely under prefers-reduced-motion: reduce and substitute a static state or a simple opacity fade:

@media (prefers-reduced-motion: reduce) {
  .card {
    /* remove the view-linked animation entirely */
    animation: none;
    opacity: 1;
    translate: none;
  }

  .reading-progress {
    animation: none;
    /* hide it or show as a static thin line */
    display: none;
  }
}

For viewport-triggered animations, replace the motion-based reveal with an instant state change:

@media (prefers-reduced-motion: reduce) {
  [data-reveal] {
    opacity: 1 !important;
    translate: none !important;
    transition: none !important;
  }
}

Browser Support Strategy (2026)

CSS Scroll-Driven Animations reached Baseline status in 2024. It is broadly supported in Chrome 115+, Edge 115+, Firefox 110+, and Safari 18 (macOS Sequoia). As of 2026, global support sits above 90%.

For the remaining users, a graceful-degradation strategy works well:

/* Base state — fully visible, no animation */
.card {
  opacity: 1;
  translate: none;
}

/* Progressive enhancement — only if supported */
@supports (animation-timeline: scroll()) {
  .card {
    opacity: 0;
    translate: 0 24px;
    animation: fade-up ease-out both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

The @supports query wraps the animated state. Users on older browsers see content normally — they never see invisible cards. Users on modern browsers get the enhanced experience. This is progressive enhancement applied to motion.

Common Anti-Patterns

Content invisible by default, revealed on scroll. If a page’s primary content (headings, body text, key CTAs) only appears via scroll animation, users with JavaScript disabled, bots, and screen readers may never see it. Always ensure the default CSS state is readable.

Animating on every scroll event with requestAnimationFrame. Even a well-optimized RAF loop is slower than the native CSS Scroll-Driven Animations compositor path. Prefer the native API; fall back to JavaScript only when the animation logic is genuinely too complex for CSS.

Parallax with large depth ratios. A parallax ratio of 0.5 means the background moves at half the scroll speed — visually dramatic but physiologically risky for vestibular-sensitive users. Keep ratios below 0.2 for ambient parallax. For anything stronger, either disable it under prefers-reduced-motion or remove it entirely.

Scroll-jacking. Overriding scroll-behavior or using JavaScript to manually move the scroll position in response to scroll events (common in full-page snap libraries) removes user agency and breaks browser scroll heuristics. Use the browser’s native scroll snap (scroll-snap-type) for section-by-section paging — it gives you snapping without hijacking scroll.

Outdated habit: jQuery .scroll() listeners. The old pattern of polling $(window).scroll() and comparing offsetTop values is obsolete. It runs on the main thread, does not batch observations, and cannot match the performance of the native APIs available today.