Home / Snippets / Layout /

Fade-in on scroll

CSS scroll-driven animations fade and lift elements into view as they enter the viewport — no JavaScript required.

Scroll-driven animations Tied directly to scroll position — no JS, no Intersection Observer
Declarative syntax animation-timeline: view() links the animation to element visibility
Progressive enhancement Wrapped in @supports — graceful fallback for older browsers
New Feature
animationno-js

Quick implementation

/* HTML: Add .fade-in to any element you want to animate in:
<div class="fade-in">...</div> */

@keyframes fade-up-in {
  from {
    opacity: 0;
    translate: 0 1.5rem;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

/* Progressive enhancement — only applies where supported */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .fade-in {
      animation: fade-up-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 30%;
    }
  }
}

/* Staggered variant — add --delay to each element */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .fade-in--stagger {
      animation: fade-up-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 35%;
      animation-delay: var(--delay, 0ms);
    }
  }
}

Prompt this to your LLM

Includes role, constraints, two framework variants, and edge cases to handle.

You are a senior frontend engineer implementing modern CSS scroll-driven animations.

Goal: Fade and slide elements up as they enter the viewport using CSS scroll-driven animations — animation-timeline: view() — with no JavaScript and no Intersection Observer.

Technical constraints:
- Use animation-timeline: view() to link animation progress to element visibility in the viewport.
- Use animation-range: entry 0% entry 30% so the animation completes as the element enters — not as it exits.
- Wrap all scroll-driven animation rules in @supports (animation-timeline: view()) for progressive enhancement.
- Also wrap in @media (prefers-reduced-motion: no-preference) — users who prefer reduced motion must not see the animation at all.
- Use the translate property (not transform: translateY) for the slide effect.
- animation-fill-mode: both ensures elements stay visible after the animation completes.

Framework variant (pick one):
A) Plain CSS utility class .fade-in — drop onto any element.
B) React component <FadeIn> — renders a wrapper div with the class applied, accepts delay (ms) and direction (up | left | right) props.

Edge cases to handle:
- Elements already in the viewport on page load should still animate in on first render.
- Staggered lists: accept a --delay custom property on each child to offset timing.
- Elements inside overflow: hidden containers may not trigger correctly — document this limitation.
- animation-fill-mode: both can cause opacity: 0 flash before the timeline attaches — test in Chrome and Safari.

Return CSS only (or React + CSS for variant B).

Why this matters in 2026

Scroll-triggered fade-ins were previously only achievable with JavaScript's IntersectionObserver API, requiring event listener setup, cleanup, and careful handling of elements already in the viewport. CSS scroll-driven animations, now supported in Chrome, Edge, and Firefox with Safari support added in 2025, eliminate all of that boilerplate entirely. The animation is declarative — defined once in CSS and driven directly by the browser's scroll position — which also means it runs off the main thread without any layout recalculation. For content-heavy pages with many animated elements, this is a meaningful performance improvement over JavaScript-based alternatives.

The logic

The animation-timeline: view() declaration makes the animation progress track the element's position within the viewport rather than elapsed time. The animation-range property controls which segment of that timeline the animation covers — entry 0% entry 30% means the full fade and slide completes within the first 30% of the element's entry into the viewport, so it feels snappy rather than stretched across the entire scroll distance. The @keyframes moves from opacity: 0 and translate: 0 1.5rem to their final values, and animation-fill-mode: both holds the element at its from state until the animation timeline begins, preventing a flash of the visible state before the scroll position is computed.

Accessibility & performance

Wrapping the animation in @media (prefers-reduced-motion: no-preference) — rather than the commonly misused prefers-reduced-motion: reduce — means the animation is strictly opt-in: only users whose operating system reports no motion preference will see it. The @supports (animation-timeline: view()) wrapper ensures the rule is ignored in browsers that do not yet support scroll-driven animations, so content remains fully visible as a fallback. Both opacity and translate are compositor-promoted properties, meaning the animation runs on the GPU without triggering layout or paint in supporting browsers. The translate property is used instead of transform: translateY() to avoid accidentally compositing with other transform values on the element.