---
title: "Fade In on Scroll with CSS Scroll-Driven Animations"
description: "Reveal content on scroll with animation-timeline: view(), add an IntersectionObserver fallback, and never hide content that only JavaScript can show."
url: https://frontendhelpers.com/css-fade-in-on-scroll
updated: 2026-10-12
---

# Fade in on scroll with scroll-driven CSS

Reveal content as it enters the viewport using `animation-timeline: view()`, fall back to a tiny IntersectionObserver, and make sure nothing stays hidden when the effect cannot run.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

Scroll inside this box ↓

Pricing

Features

Testimonials

Case studies

Integrations

Security

Changelog

Contact

No `view()` support here, so items simply show.

**Fade up on entry** — `view()` inside a scroll container, `entry 0% cover 30%`; scroll back up and it reverses.

Scroll inside this box ↓

Plan

Design

Build

Test

Launch

Measure

Iterate

Repeat

No `view()` support here, so items simply show.

**Spotlight band** — one keyframe set over the whole `cover` range: fades in, holds, then fades out at the top.

Scroll inside this box ↓

Order placed

Payment taken

Packed

Dispatched

In transit

Out for delivery

Delivered

Review requested

**IntersectionObserver, once** — the script hides only off-screen items, reveals each on first sight, then unobserves it; works in every current browser.

## Two ways to reveal on scroll

A fade-in on scroll lets a section arrive as the reader reaches it instead of sitting there from the start. For years that meant a scroll listener measuring every element. There are now two better options. CSS scroll-driven animations tie a normal `@keyframes` animation to the element’s position in its scroll container, with no script at all. IntersectionObserver is the JavaScript route, and it lets the browser tell you when an element becomes visible rather than making you ask on every scroll event.

This recipe uses the CSS version where supported, keeps the observer as a fallback, and makes sure that if neither runs, the reader still sees every word.

## The CSS-only reveal with `animation-timeline: view()`

Normally an animation’s progress is driven by time. The `animation-timeline` property swaps that clock for a scroll position. MDN’s [animation-timeline reference](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline) describes `view()` as an anonymous view progress timeline on the nearest ancestor scroll container. The animation sits at 0% as the element enters the scrollport and reaches 100% as it leaves.

That “nearest ancestor scroll container” is why the first two demos work inside small boxes. You do not need page scroll; any element with `overflow-y: auto` and content taller than itself will do. On a real page the nearest scroller is usually the viewport, and the same CSS works unchanged.

```css
@keyframes fs-rise {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  .fs-rise {
    animation: fs-rise linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}
```

Three details in that block are easy to get wrong. First, order: the `animation` shorthand resets `animation-timeline` to `auto`, so the timeline must come after it. The Chrome team’s [scroll-driven animations guide](https://developer.chrome.com/docs/css-ui/scroll-driven-animations) calls this out explicitly. Second, there is no duration. A time in seconds means nothing on a scroll timeline, so leave it out and let it default to `auto`. Third, `linear` easing and a fill mode of `both`: linear keeps the effect locked to the scrollbar, and `both` holds the first keyframe before the range starts and the last one after it ends.

One trap catches almost everyone eventually. A wrapper with `overflow-x: hidden` becomes a scroll container in both directions. `view()` then tracks that wrapper instead of the viewport, and because the wrapper never scrolls, nothing animates. Use `overflow-x: clip`, which clips without creating a scroll container.

## Reading `animation-range: entry 0% cover 30%`

Without a range the animation would play across the whole journey through the scrollport, finishing only as the element disappeared off the top, which is no use for a reveal. `animation-range` picks a slice of that journey. MDN’s [animation-range page](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-range) lists the named ranges, and four matter here:

-   `cover`: the full journey, from the element’s first pixel entering to its last pixel leaving.
-   `entry`: from first pixel in to fully inside (or filling the scrollport, if it is taller than it).
-   `exit`: the mirror of `entry` at the top edge.
-   `contain`: the stretch where the element is fully inside the scrollport, or fully covers it.

So `entry 0% cover 30%` starts the fade the moment the element’s top edge appears and finishes it 30% of the way through the full journey, which in a typical viewport is roughly a third of the way up the screen. Starting later than 0% leaves an empty gap at the bottom of the screen; finishing at `entry 100%` makes tall elements snap in and short ones fade almost too fast to notice. Ranges are measured on the element’s untransformed box, so the `translateY()` in the keyframes does not shift its own trigger points.

The second demo uses the full range and puts the timing into the keyframes instead, so each item fades in, holds while it is in the middle band, and fades out near the top:

```css
@keyframes fs-spotlight {
  0%       { opacity: 0; transform: translateY(16px) scale(0.96); }
  30%, 70% { opacity: 1; transform: none; }
  100%     { opacity: 0; transform: translateY(-16px) scale(0.96); }
}

@supports (animation-timeline: view()) {
  .fs-spotlight {
    animation: fs-spotlight linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
  }
}
```

Use the fade-out sparingly: it suits a short sequence like a process diagram, not body text.

## Browser support in October 2026

Scroll-driven animations shipped in Chrome and Edge 115 in July 2023 and in Safari 26, on macOS and iOS, in September 2025. Firefox is the holdout. MDN’s list of [Firefox experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features) shows them enabled by default only in Nightly, from version 136; in Beta and Release they sit behind the `layout.css.scroll-driven-animations.enabled` preference, which is off. MDN therefore marks `animation-timeline` as “Limited availability”, not Baseline.

That is why every scroll-driven rule in this recipe sits inside `@supports (animation-timeline: view())`. The `@keyframes` can live outside it, because keyframes do nothing until an `animation` rule uses them. A browser that fails the test never applies the animation, so it never applies the 0% keyframe either: the content is simply there, fully opaque, in its final position. That is the correct result. Never set `opacity: 0` outside the `@supports` block and rely on the animation to undo it.

## Never hide content that only JavaScript can reveal

The single most damaging version of this effect is also the most copied:

```css
/* Don’t: invisible to everyone until a script runs */
.reveal { opacity: 0; }
.reveal.is-visible { opacity: 1; }
```

Here the stylesheet hides the content and only a script can bring it back. If that script fails to download, is blocked by an extension, throws an error earlier in the bundle, or simply has not executed yet when a crawler takes its snapshot, the page is blank below the fold. Readers and search engines alike see empty space.

The rule is that the default, script-free state must be the finished state. The common fix is a class on the root element, set by a one-line script in the `<head>`, with the hiding rules scoped to `html.js .reveal`. Better, but it still breaks if the observer script later fails. The demo goes one step further: the observer script itself adds the hiding class, `fs-wait`, and only to items the observer has just reported as out of view. Hiding and revealing cannot be separated, and anything already on screen is never hidden, so nothing flickers.

## The IntersectionObserver fallback

IntersectionObserver has worked in every major browser since 2019; MDN’s [IntersectionObserver reference](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) lists it as Baseline widely available. You hand the browser a list of targets and it calls you back when their visibility crosses a threshold. The callback still runs on the main thread, but only when something changes.

```css
.fs-io.fs-wait {
  opacity: 0;
  transform: translateY(24px);
}
.fs-io:not(.fs-wait) {
  transition: opacity 0.6s ease, transform 0.6s ease;
}
```

```js
const scroller = document.getElementById('fs-io-scroller');
if (scroller && 'IntersectionObserver' in window) {
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        entry.target.classList.remove('fs-wait');
        io.unobserve(entry.target);
      } else {
        entry.target.classList.add('fs-wait');
      }
    }
  }, { root: scroller, threshold: 0.2 });

  scroller.querySelectorAll('.fs-io').forEach((el) => io.observe(el));
}
```

The observer’s first callback reports every target, which is where off-screen items get `fs-wait`; items already visible are simply left alone. After that, `threshold: 0.2` fires once a fifth of an element is visible, and removing the class lets the transition play. The `root` option exists only because the demo lives in a box; on a page, omit it. Calling `unobserve()` makes it play once and stops the browser tracking the element. To use this purely as a fallback, return early when `CSS.supports('animation-timeline', 'view()')` is true, and observe your page’s reveal elements with no `root`.

Unlike the CSS version, this reveal is a timed transition. It plays at its own speed once triggered, and it does not reverse when you scroll back up.

| Approach | JS needed | Runs on main thread? | Support (Oct 2026) | Best for |
| --- | --- | --- | --- | --- |
| `animation-timeline: view()` | None | No, for `opacity` and `transform`; the compositor tracks scroll | Chrome and Edge 115+, Safari 26+; Firefox only in Nightly or behind a flag | Reveals tied to scroll position that reverse naturally |
| IntersectionObserver + class | About 14 lines | Callback yes, but only on visibility changes; the transition itself can be composited | All current browsers (Baseline since 2019) | One-shot reveals, and the fallback for Firefox |
| Scroll listener + `getBoundingClientRect()` | Yes | Yes, on every scroll event, forcing layout | All browsers | Nothing new; replace it with one of the above |
| No animation | None | Nothing to run | All browsers | Above-the-fold content, body text, reduced motion |

## Animate opacity and transform, nothing else

All three demos move only `opacity` and `transform`, and that is deliberate. Those two properties can be handled by the compositor, so the browser does not repaint or reflow the element each frame. The Chrome guide notes that scroll-driven animations of such properties run off the main thread, which means a heavy script cannot make them stutter. Animate `margin-top`, `height` or `filter: blur()` instead and you are back to layout or paint work on every frame of scrolling. Our guide to [CSS animation performance](https://frontendhelpers.com/css-animation-performance) explains the rendering pipeline behind this.

## Keep it below the fold, and off most paragraphs

Never fade in the content people see first. web.dev’s [Largest Contentful Paint article](https://web.dev/articles/lcp) notes that Chromium does not count elements with an opacity of 0 as LCP candidates, so a hero heading or image that starts invisible pushes your LCP back until it starts to appear.

Layout shift is less of a worry as long as you stick to `transform`: moving an element with `translateY()` does not count towards Cumulative Layout Shift, whereas revealing content by changing `height`, `margin` or `display` does. Our [Core Web Vitals guide](https://frontendhelpers.com/core-web-vitals-guide) covers how both metrics are measured.

Then be selective. Reveal section headings, cards or images, the things that mark a new chunk of content, not every paragraph. When each line of text fades in, scrolling at reading speed means the words you want are always half-transparent, and the novelty wears off by the second screen.

## Reduced motion: fade, don’t travel

The `prefers-reduced-motion` media feature reports the user’s system setting to minimise motion. For a reveal, the travel is the part that can cause discomfort; a change in opacity is not motion. So under reduced motion, swap the keyframes for opacity-only versions, or remove the animation entirely if you prefer:

```css
@keyframes fs-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: reduce) {
    .fs-rise { animation-name: fs-fade; }
    .fs-spotlight { animation-name: fs-fade-spotlight; }
  }
}

@media (prefers-reduced-motion: reduce) {
  .fs-io.fs-wait { transform: none; }
}
```

Turn on reduced motion in your operating system and scroll the demos again: everything still fades, nothing slides. If you want a ready-made, time-based version for content that should animate on load rather than on scroll, the [fade-in-up card in our library](https://frontendhelpers.com/css-entrance-animations#anim-fade-in-up) is the same keyframes with a duration, and the [CSS entrance animations](https://frontendhelpers.com/css-entrance-animations) category has the rest of the family.

## Frequently asked questions

### Does animation-timeline work in Safari and Firefox?

Safari supports it from version 26, released in September 2025, on macOS and iOS. As of October 2026 Firefox enables it by default only in Nightly builds; in release versions it sits behind the layout.css.scroll-driven-animations.enabled preference, which is off. Wrap the rules in @supports (animation-timeline: view()) so Firefox users simply see the content.

### Why does my scroll-driven animation not play?

The usual cause is declaring the animation shorthand after animation-timeline, which resets the timeline to auto so the animation jumps straight to its end state. Put animation first and animation-timeline after it. The other common cause is a wrapper with overflow-x: hidden, which turns it into the scroll container that view() tracks. Use overflow-x: clip instead.

### Will fade-in-on-scroll hurt SEO or Core Web Vitals?

Not if content is visible by default and the effect stays below the fold. Animating opacity and transform causes no layout shift. The risks are a hero that starts at opacity 0, which delays Largest Contentful Paint, and CSS that hides content until a script runs, which leaves it invisible whenever that script fails.

### Should I use IntersectionObserver or scroll-driven animations?

Use scroll-driven animations when the reveal should follow the scroll position and work without JavaScript. Use IntersectionObserver when the reveal should play once at its own speed, or must run in every current browser. A good compromise is the CSS version everywhere it is supported, with the observer loaded only where it is not.

## Keep going

- [CSS entrance animations](https://frontendhelpers.com/css-entrance-animations): Fades, slides and zooms for content arriving on screen, each with markup and CSS.
 
- [CSS animation performance](https://frontendhelpers.com/css-animation-performance): Which properties the compositor can animate, and how to check in DevTools.
 
- [Core Web Vitals](https://frontendhelpers.com/core-web-vitals-guide): How LCP, INP and CLS are measured and what fixes them.
