Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 12.5Intermediate ~22 min · shot 5 of 7

Scroll-Triggered Animation

In one line

A scroll-triggered animation is an ordinary timed animation that starts when scrolling reaches a point. IntersectionObserver is the tool every browser has, and CSS timeline-trigger is the newer way, still not safe to depend on.

0/7 · 0%

Think of it like this

An automatic door. It does not follow your steps: when you reach the sensor, it opens in its own time. A scroll-driven animation is a door that opens exactly as far as you walk. A scroll-triggered one is the sensor door: scrolling only decides when it starts.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

IntersectionObserver
A browser object that calls your function when an element enters or leaves a box (usually the screen), without a scroll listener.
threshold
The share of the element that must be visible for the observer to report: 0 for a single pixel, 1 for all of it, or a list.
rootMargin
A margin that grows or shrinks the observed box, written like CSS margins: "0px 0px -10% 0px".
scroll-triggered
An animation that runs on the clock, started by scrolling reaching a point. Not the same as scroll-driven.

Step by step

01Read the callbacks

In "What the observer reports" the first two lines come before any scrolling: the top card is in view, the lower card is not.

02The jump

Jumping straight to the bottom reports only c1 in view: false: the lower card was never visible at a frame, so it has no entry.

03Once or every time

In "Once, or every time" the left box keeps both cards lit and the right box dims the card that leaves.

04The CSS trigger

"A trigger from CSS" plays a 350 ms animation forward when the card is fully in, and backward when it starts to leave.

Try it yourself

  1. 1

    Reveal sooner with rootMargin

    In "What the observer reports" add rootMargin: "0px 0px 60px 0px" to the options. The box is now 60 px taller at the bottom, so the lower card reports true 60 px of scroll sooner, before it can be seen. A negative bottom margin (-60px) does the opposite: it waits until the card is 60 px inside.

Code & diagrams

What the observer reports

One observer, two cards, a 100 px scroller as the root. The script scrolls for you. The first two lines come from the observer's first call.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

c1 in view: true
c2 in view: false
-- scroll down in steps
c1 in view: false
c2 in view: true
-- back to the top, then jump past the second card
c1 in view: true
c2 in view: false
c1 in view: false
scrollTop is 596, and the second card never reported again
Once, or every time

Two boxes, the same cards. `once` unobserves after the first sighting and also counts a card above the box as seen. It takes about two seconds.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

once: shown at load 1 | every time: shown at load 1
once: after scrolling down 1+2 | every time: after scrolling down 2
once: after scrolling back 1+2 | every time: after scrolling back 1
A trigger from CSS

Chrome 145 and later, and the course's Chromium 153. Where it is missing, the `@supports` block does not apply and the cards simply show.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

timeline-trigger supported: true
at load: top card finished 1.00, lower card paused 0.00
scrolled down: top card finished 0.00, lower card finished 1.00
back up: top card finished 1.00, lower card finished 0.00

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Hide the cards in plain CSS, then break the script

In "Once, or every time" the cards are dimmed (opacity: 0.25). Change the CSS to opacity: 0, and in the script rename IntersectionObserver to IntersectionObserverX.

terminal
$ Run it
── what you'll see ──
`ReferenceError: IntersectionObserverX is not defined` in the console, and both boxes are empty: the cards are at opacity 0 and nothing will ever show them.

Myth vs fact

Myth

Scroll-triggered animation needs a scroll event listener.

Fact

IntersectionObserver tells you without one, and it is called only when something crosses, not on every scroll.

Remember this

  1. 1

    The problem this solves: sometimes you want a finished, timed effect (a card that pops in over 350 ms) whatever the scroll speed: a scroll-driven one would stop half-way. So you need to know when something enters view, and then run a normal animation.

  2. 2

    **IntersectionObserver is the tool.** new IntersectionObserver(callback, options) tells you when an element crosses into or out of a box (the root, by default the viewport). Entries carry isIntersecting and intersectionRatio. threshold is the ratio at which to report (0 = one pixel, 1 = fully visible). rootMargin grows or shrinks the box ("0px 0px -10% 0px" ignores the bottom tenth). The browser calls you only when something crosses, not on every scroll event.

  3. 3

    It always calls you once at the start. When you observe() an element, you get one entry at once: isIntersecting: true if it is already in view, false if not. So an element on screen at load is handled by the same code as one that arrives later (first example: the top card reports true, the lower false).

  4. 4

    Once or every time? Once: after the first true, call observer.unobserve(el); the element keeps its state and costs nothing more. Every time: toggle a class with isIntersecting. The second example runs both: after scrolling down and back, "once" shows 1+2 and "every time" shows only 1. Reveal content once; replay suits things like video.

  5. 5

    Edge cases. A fast scroll can jump past an element without it being in view at any frame: the first example jumps to 596 px, and the lower card never reports at all. That is right. A page restored half-way reports everything above the screen as isIntersecting: false with a negative boundingClientRect.top: show those at once, as the build does. Hide only when something will show: the script adds the hidden state (a class on html), so a failed script never leaves content invisible.

  6. 6

    **The newer CSS way: timeline-trigger.** timeline-trigger: --t view() entry 100% exit 0% defines a trigger active between those two points of the element's view timeline. animation-trigger: --t play-forwards play-backwards plays a normal timed animation forward when it activates and backward when it deactivates. Measured in Chromium 153 (third example): the top card plays at load and the lower waits; scrolling down plays the lower forward and the top backward; back up reverses it.

  7. 7

    Where it stands. Chrome's developer blog (2025-12-12) announced scroll-triggered animations for Chrome 145. No other browser is confirmed here, and the syntax is new: use it only as an enhancement behind @supports, and prefer the observer for anything you must ship today.

Explain it without notes

01

What is the difference between a scroll-driven and a scroll-triggered animation, and when do you choose each?

02

Why does an observer need to handle elements above the viewport, and how?

Practice

01

Write a revealOnce(selector) that adds the class shown to each element when 20% of it is visible, handles elements that are above the screen, and stops observing finished ones.

02

Add a guard so that nothing is hidden at all when prefers-reduced-motion: reduce matches.

Done when you can

  • You can say how scroll-triggered differs from scroll-driven

  • You can write an IntersectionObserver with threshold and rootMargin, once or every time

  • You handle elements already in view, above the screen, and jumped over

  • You keep content visible without the script, and know where timeline-trigger stands