---
name: scroll-triggered-animation
description: Use when you want "Modern, polished, narrative" - Starts animations when sections enter the viewport.
---

# Scroll-Triggered Animation

> **Category:** Motion / Interaction  -  **Personality:** Modern, polished, narrative
>
> **Best use:** Landing pages, product pages
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

As the reader scrolls a long landing page, each block "arrives" with its own motion — copy rises, images slide in or wipe open, stat cards flip up, a pull-quote de-blurs, and big numbers count up from zero. A single `IntersectionObserver` watches every element and toggles a class the moment it crosses into view, so the page tells a story at the reader's own pace. Unlike a plain "Scroll Fade-In", the motion is **varied per block** (direction / scale / rotation / clip-wipe / blur + animated counters) and **replays every time** a block re-enters, which keeps a long page feeling alive on the way back up. It is the default narrative device for modern product and landing pages.

## Dependencies / CDN

**None - vanilla JS** (`IntersectionObserver` + `requestAnimationFrame`, both baseline in every current browser). Display/body fonts are 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=Darker+Grotesque:wght@700;800;900&family=Onest:wght@400;500;600&display=swap" rel="stylesheet">
```

## HTML

Tag each block with `data-anim="<variant>"` (the reveal style). Numbers carry `data-count` plus optional `data-decimals` / `data-suffix`. Children of a group get an inline `--i` index for stagger. The visible text is the real value, so it still reads correctly with JS off.

```html
<!-- scroll container = the IntersectionObserver root -->
<div class="screen" tabindex="0" role="region" aria-label="Scroll to trigger animations">

  <section class="hero">
    <h1   data-anim="rise">Travel past the edge of the <em>map</em>.</h1>
    <p    data-anim="rise" style="--i:1">Eighteen-guest expeditions to the places the brochures forgot.</p>
    <figure data-anim="scale"><img src="/img/landscape-mountain.jpg" alt="Granite spires"></figure>
  </section>

  <section class="stats">
    <div data-anim="left"  style="--i:0"><span class="num" data-count="128" data-suffix="+">128+</span><p>Expeditions led</p></div>
    <div data-anim="flip"  style="--i:1"><span class="num" data-count="4.97" data-decimals="2">4.97</span><p>Guest rating</p></div>
    <div data-anim="right" style="--i:2"><span class="num" data-count="92" data-suffix="%">92%</span><p>Book again</p></div>
  </section>

  <figure class="shot" data-anim="clip"><img src="/img/landscape-coast.jpg" alt="Coastline"></figure>
  <blockquote data-anim="blur">The most quietly transformative two weeks of my life.</blockquote>
</div>
```

## CSS

The whole "hidden then reveal" system is **armed only once JS adds `.is-ready`** — so without JS (or under reduced motion) every block is simply shown.

```css
/* Transition is always defined; the hidden states below are gated by .is-ready */
[data-anim]{
  transition: opacity .85s cubic-bezier(.16,.84,.32,1),
              transform .85s cubic-bezier(.16,.84,.32,1),
              filter .7s ease, clip-path .9s cubic-bezier(.16,.84,.32,1);
  transition-delay: calc(var(--i,0) * 85ms);     /* per-child stagger */
  will-change: opacity, transform;
}

/* Resting (hidden) states — one per variant. Mix & match freely. */
.is-ready [data-anim]            { opacity:0 }
.is-ready [data-anim="rise"]     { transform: translateY(46px) }
.is-ready [data-anim="fall"]     { transform: translateY(-32px) }
.is-ready [data-anim="left"]     { transform: translateX(-58px) }
.is-ready [data-anim="right"]    { transform: translateX(58px) }
.is-ready [data-anim="scale"]    { transform: scale(.82) }
.is-ready [data-anim="flip"]     { transform: perspective(1100px) rotateX(42deg); transform-origin:center top }
.is-ready [data-anim="blur"]     { transform: translateY(22px); filter: blur(15px) }
.is-ready [data-anim="clip"]     { opacity:1; clip-path: inset(0 100% 0 0) }   /* left-to-right wipe */

/* The single "revealed" rule that all variants animate toward */
.is-ready [data-anim].in{ opacity:1; transform:none; filter:none; clip-path:inset(0 0 0 0) }

.num{ font-variant-numeric: tabular-nums }   /* stops counters from jittering width */
```

## JavaScript

One observer drives both the reveals and the counters; both **toggle on enter and reset on leave**, so the effect replays. Reduced-motion and unsupported browsers fall through to "everything shown, final numbers".

```js
(function(){
  var root     = document.querySelector('.screen');          // scroll container = observer root
  var reduce   = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var ok       = 'IntersectionObserver' in window;
  var counters = document.querySelectorAll('[data-count]');

  function fmt(v, el){
    var d = +(el.getAttribute('data-decimals') || 0);
    var s = Number(v).toLocaleString('en-US', {minimumFractionDigits:d, maximumFractionDigits:d});
    return (el.getAttribute('data-prefix')||'') + s + (el.getAttribute('data-suffix')||'');
  }
  function runCount(el){
    cancelAnimationFrame(el._raf);
    var target = parseFloat(el.getAttribute('data-count')) || 0, dur = 1500, t0 = null;
    (function step(ts){
      if(t0===null) t0 = ts;
      var p = Math.min((ts-t0)/dur, 1), e = 1 - Math.pow(1-p, 3);   // easeOutCubic
      el.textContent = fmt(target*e, el);
      if(p < 1) el._raf = requestAnimationFrame(step);
    })(performance.now());
  }
  function resetCount(el){ cancelAnimationFrame(el._raf); el.textContent = fmt(0, el); }

  // Shown-as-is: reduced motion or no support -> settle final numbers and stop.
  if(reduce || !ok){
    [].forEach.call(counters, function(el){ el.textContent = fmt(parseFloat(el.getAttribute('data-count'))||0, el); });
    return;
  }

  document.body.classList.add('is-ready');                     // arm the hidden->reveal states
  [].forEach.call(counters, function(el){ el.textContent = fmt(0, el); });

  var io = new IntersectionObserver(function(entries){
    entries.forEach(function(en){
      var el = en.target;
      el.classList.toggle('in', en.isIntersecting);            // add on enter, remove on leave
      if(el.hasAttribute('data-count')) en.isIntersecting ? runCount(el) : resetCount(el);
    });
  }, { root: root, rootMargin: '0px 0px -12% 0px', threshold: 0.18 });

  document.querySelectorAll('[data-anim],[data-count]').forEach(function(el){ io.observe(el); });
})();
```

## How it works

- **`IntersectionObserver`** fires a callback whenever an observed element crosses the viewport (or a chosen scroll `root`) boundary. No scroll listener, no `getBoundingClientRect()` in a loop — the browser batches it off the main thread.
- **Class toggle, not inline styles.** Entering adds `.in`; the CSS transition between the resting variant (`translateY`, `scale`, `rotateX`, `clip-path`…) and the shared `.in` rule does all the motion. One revealed rule serves every variant.
- **Reset on leave = replay.** Because `.in` is *removed* when a block scrolls out, it re-animates the next time it returns. Counters mirror this: `runCount` on enter, `resetCount` to zero on leave.
- **Counters** tween `0 → target` over 1.5 s with an easeOutCubic curve via `requestAnimationFrame`, formatted with `toLocaleString` (decimals, `%`/`+` suffixes, thousands separators).
- **Stagger** is pure CSS: `transition-delay: calc(var(--i) * 85ms)` on grouped children, so cards in a row cascade.
- **`rootMargin: '0px 0px -12% 0px'`** trips each block slightly before it reaches the bottom edge, so motion starts as the block is comfortably on screen rather than the instant a single pixel appears.

## Customization

- **New variant:** add one resting rule, e.g. `.is-ready [data-anim="skew"]{ transform: skewY(7deg) translateY(40px) }`. The shared `.in` rule already returns it to neutral — no second rule needed.
- **Pace & feel:** tune the `transition` duration/easing for snappier or more languid arrivals; change the stagger multiplier (`85ms`).
- **Fire once (no replay):** in the callback, `if(en.isIntersecting){ el.classList.add('in'); io.unobserve(el); }` instead of toggling.
- **Trigger point:** raise `threshold` or push `rootMargin`'s bottom value more negative to delay the reveal until blocks are deeper in view.
- **Counter speed/format:** change `dur`, or add `data-prefix="$"`, `data-suffix=" km"`, `data-decimals="1"`.
- **Page vs. panel:** drop the `root` option entirely to observe against the real viewport for a full-page landing.

## Accessibility & performance

- **Respect `prefers-reduced-motion`.** Because the hidden states are gated behind `.is-ready` and JS never adds that class under reduced motion, the page renders fully visible with the final numbers — no motion, nothing hidden.
- **No-JS safe.** Same gating means content is visible by default; the real values live in the HTML, so search engines and JS-off users see everything.
- **Only animate `transform`, `opacity`, `filter`, `clip-path`** — all compositor-friendly. Avoid animating layout properties (`top`, `height`, `margin`).
- **`will-change`** is set on animated elements; keep the observed set reasonable (dozens, not thousands) and let `IntersectionObserver` retire offscreen work for you.
- Give a scrollable inner panel `tabindex="0"` + `role="region"` + an `aria-label` so keyboard users can scroll it; `font-variant-numeric: tabular-nums` stops counters from reflowing as digits change.

## Gotchas

- **A custom `root` must be the scroll container itself** (here, `.screen`), and its ancestor needs a fixed height + `overflow:auto`. Pass the wrong element and `isIntersecting` is always `true`.
- **Don't put the hidden state on the base selector** (`[data-anim]{opacity:0}`). If JS fails to run, the page stays blank forever. Gate it behind a JS-applied class (`.is-ready`) — fail open, not closed.
- **`clip-path` reveals need `opacity:1`** in their resting state (they wipe, not fade) and the property listed in `transition`, or they snap.
- **Counters jitter width** as digits change unless you use `tabular-nums` (or a fixed-width container).
- **`overscroll-behavior: contain`** on an inner scroll panel keeps wheel/touch from chaining to the page mid-scroll — usually what you want for a framed demo.
- The catalog's global `@media (prefers-reduced-motion: reduce)` zeroes transition *durations*; relying on it alone would leave `opacity:0` blocks stuck invisible — which is exactly why the resting state is gated behind `.is-ready` and skipped under reduced motion.
