Shots in this scene
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.
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
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 reportstrue60 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
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.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
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 againTwo 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.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
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 1Chrome 145 and later, and the course's Chromium 153. Where it is missing, the `@supports` block does not apply and the cards simply show.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
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.00Break 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.
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
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
**
IntersectionObserveris the tool.**new IntersectionObserver(callback, options)tells you when an element crosses into or out of a box (theroot, by default the viewport). Entries carryisIntersectingandintersectionRatio.thresholdis the ratio at which to report (0= one pixel,1= fully visible).rootMargingrows 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
It always calls you once at the start. When you
observe()an element, you get one entry at once:isIntersecting: trueif it is already in view,falseif not. So an element on screen at load is handled by the same code as one that arrives later (first example: the top card reportstrue, the lowerfalse). - 4
Once or every time? Once: after the first
true, callobserver.unobserve(el); the element keeps its state and costs nothing more. Every time: toggle a class withisIntersecting. 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
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: falsewith a negativeboundingClientRect.top: show those at once, as the build does. Hide only when something will show: the script adds the hidden state (a class onhtml), so a failed script never leaves content invisible. - 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-backwardsplays 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
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
What is the difference between a scroll-driven and a scroll-triggered animation, and when do you choose each?
Why does an observer need to handle elements above the viewport, and how?
Practice
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.
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
IntersectionObserverwiththresholdandrootMargin, once or every timeYou handle elements already in view, above the screen, and jumped over
You keep content visible without the script, and know where
timeline-triggerstands