---
name: scroll-fade-in
description: Use when you want "Clean, professional, simple" - Fades content into visibility as it enters the viewport.
---

# Scroll Fade-In

> **Category:** Scroll  -  **Personality:** Clean, professional, simple
>
> **Best use:** Most websites
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 1/5 - Visual impact 3/5 - Difficulty 1/5 - Performance cost 1/5

## What it is

Scroll Fade-In is the everyday "content appears as you scroll" effect: each section starts invisible and shifted down a little, then eases up to full opacity once it crosses into the viewport. It is driven by a native `IntersectionObserver` that simply toggles a class — the actual motion is a plain CSS `transition` on `opacity` and `transform`. Because it toggles, the reveal **replays** every time an element re-enters view. It is the safest, lowest-cost way to make a marketing page feel alive without looking flashy, which is why almost every modern site uses some version of it.

## Dependencies / CDN

**None** — native `IntersectionObserver` + a CSS transition. No library, no GSAP.

The demo only loads two display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Mark anything you want to reveal with one class. Use an optional `--d` custom property to stagger neighbours:

```html
<section>
  <h2 class="reveal">Headline rises into view</h2>
  <p class="reveal" style="--d:.1s">…and this follows a beat later.</p>
  <div class="reveal" style="--d:.2s">…staggered with one CSS variable.</div>
</section>
```

## CSS

```css
.reveal{
  opacity:0;
  transform:translateY(28px);
  transition:opacity .72s cubic-bezier(.22,.61,.36,1),
             transform .72s cubic-bezier(.22,.61,.36,1);
  transition-delay:var(--d,0s);          /* per-element stagger */
  will-change:opacity,transform;
}
.reveal.is-in{ opacity:1; transform:none; }

/* Reduced motion: never hide content — show it immediately */
@media (prefers-reduced-motion:reduce){
  .reveal{ opacity:1; transform:none; transition:none; }
}
```

## JavaScript

```js
var reveals = [].slice.call(document.querySelectorAll('.reveal'));
var reduce  = matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reduce || !('IntersectionObserver' in window)) {
  // No motion (or no support): just show everything.
  reveals.forEach(function(el){ el.classList.add('is-in'); });
} else {
  var io = new IntersectionObserver(function(entries){
    entries.forEach(function(e){
      // Toggle -> the reveal replays each time an element re-enters view.
      e.target.classList.toggle('is-in', e.isIntersecting);
    });
  }, {
    // root: scrollPanel,            // omit to observe the browser viewport (normal page)
    rootMargin: '0px 0px -10% 0px',  // trigger ~10% before the element hits the bottom edge
    threshold: 0
  });
  reveals.forEach(function(el){ io.observe(el); });
}
```

In the demo the page lives inside a scroll panel, so `root` is set to that panel. For a normal full-page site, **drop the `root` option** and the observer watches the browser viewport instead — the rest is identical.

## How it works

- **The class is the switch.** `.reveal` defines the hidden resting state (`opacity:0` + `translateY(28px)`); adding `.is-in` defines the visible state (`opacity:1` + `transform:none`). The browser tweens between them via the `transition` — JS never animates anything.
- **`IntersectionObserver`** asynchronously reports when each observed element crosses the root's edges, off the main thread. We `toggle('is-in', e.isIntersecting)` so it both reveals on enter and re-arms on exit (the replay-on-scroll behaviour).
- **`rootMargin:'0px 0px -10% 0px'`** shrinks the trigger line up by 10% of the root height, so elements reveal a touch *after* they appear — it reads as intentional rather than reacting at the very edge.
- **`--d` stagger.** Sibling cards/stats carry `style="--d:.1s"`, `.2s`… feeding `transition-delay`, so a group cascades instead of popping in all at once.

## Customization

- **Distance / direction:** change `translateY(28px)` — smaller (`12px`) is subtler; use `translateX` for slide-in-from-side, or add `scale(.96)` for a gentle zoom.
- **Speed & feel:** tune the `.72s` duration and the `cubic-bezier`; an ease-out curve (fast then settling) looks the most polished.
- **Trigger point:** make `rootMargin` bottom more negative (`-20%`) to reveal later/deeper, or `0px` to fire the instant an element peeks in.
- **Play once:** if you don't want the replay, `io.unobserve(e.target)` inside the callback after adding `is-in`, and drop the `else`/toggle.
- **Stagger:** set `--d` per item, or compute it in JS (`el.style.setProperty('--d', i*0.08+'s')`).

## Accessibility & performance

- **Reduced motion is mandatory here.** The `@media (prefers-reduced-motion: reduce)` block forces `opacity:1`, and the JS bails to "show everything" — so content is never trapped invisible for users who disable animation (or if JS fails).
- **Cheap by design.** Only `opacity` and `transform` are animated (both GPU-compositable, no layout/paint). `IntersectionObserver` replaces costly `scroll`-handler measurement.
- **Don't fade in above-the-fold critical content** for SEO/UX reasons — hero copy should be visible immediately; reserve the effect for content the user scrolls to. (The observer reveals in-view items on load anyway.)
- Drop `will-change` if you have hundreds of reveal targets — keeping a permanent compositor layer for every one wastes memory.

## Gotchas

- **`root` must be the actual scroll container.** When your content scrolls inside a panel (as in this demo), pass that element as `root`; leave it out only when the page itself scrolls. Get this wrong and nothing ever triggers.
- **No JS = invisible content.** Since `.reveal` starts at `opacity:0`, a script error would hide everything. The reduced-motion CSS rule is the safety net; for extra safety you can add a `.no-js`/`<noscript>` override that sets `opacity:1`.
- **Percentage `rootMargin` is relative to the root**, not the element — `-10%` means 10% of the viewport/panel height.
- **Stagger delays add up:** large `--d` values on long lists make later items feel sluggish; keep increments around `.06–.12s`.
- **Re-entry direction:** because it replays via `toggle`, an element scrolled back to from below still animates from its `translateY` offset — fine for a subtle 28px, but exaggerated distances can look like they enter from the "wrong" side.
