Lesson 30 โข Advanced Track
Scroll-Based Animations
By the end of this lesson you'll be able to snap a gallery to each image, grow a reading-progress bar with pure CSS, reveal cards as they scroll into view with both modern CSS and JavaScript, and switch every effect off cleanly for people who prefer reduced motion.
What You'll Learn
transform and transition, and writing @keyframes animations. If keyframe animation feels new, review Keyframe Animations first.๐ก Think of It Like This
A normal CSS animation plays on a clock; a scroll-driven animation plays on a flipbook. With a flipbook, nothing moves until you turn the pages โ flip fast and it races, flip slowly and it crawls, stop and it freezes. Scroll-driven animations work the same way: the scrollbar becomes the play head, so the animation's progress is whatever fraction of the page you've scrolled.
IntersectionObserver is a different tool โ think of it as a motion sensor above a doorway. It doesn't track a smooth percentage; it just fires once when something crosses the threshold, and you flip a switch (add a CSS class) in response. Knowing which tool is a flipbook and which is a doorbell is half of this lesson.
1. Scroll Snap โ Lock Onto Each Item
Scroll snapping makes a scroll container come to rest neatly on each child instead of stopping mid-item. You set it up with two properties: put scroll-snap-type on the scrolling container (the parent), and scroll-snap-align on each child that should be a snap point.
scroll-snap-type: x mandatory means "snap along the x axis, and you must land on a snap point". Use y for vertical galleries, and proximity instead of mandatory if you only want it to snap when the user lets go near an item. This is pure CSS โ no JavaScript, and it works in every modern browser.
Run the worked example and drag the gallery sideways. Let go anywhere and it glides to centre the nearest card.
Worked example: a snapping image gallery
scroll-snap-type on the parent, scroll-snap-align on each child
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Scroll snap gallery</title>
<style>
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e5e7eb; padding:24px; }
.gallery {
display: flex;
gap: 16px;
overflow-x: auto; /* the container that scrolls */
scroll-snap-type: x mandatory; /* snap on the x axis; MUST land on a point */
padding-bottom: 12px; /* room for the scrollbar */
}
...2. Scroll-Driven Animations โ animation-timeline: scroll()
A normal CSS animation runs on a clock set by animation-duration. A scroll-driven animation throws that clock away and uses the scrollbar instead. You write the same @keyframes as always, then add animation-timeline: scroll() and set the duration to auto (the duration is meaningless when scroll drives it).
At 0% scrolled the animation is at its from state; at 100% scrolled it's at to. That maps perfectly to a reading progress bar that fills as you move down the page. These animations run on the compositor thread, so they stay smooth โ but as of 2026 only Chromium browsers support them, which is why section 5 adds a cross-browser fallback.
Scroll the preview and watch the rainbow bar at the top grow from empty to full.
Worked example: a scroll-driven progress bar
@keyframes + animation-timeline: scroll() with no JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Scroll progress bar</title>
<style>
body { font-family: system-ui, sans-serif; margin:0; color:#e5e7eb; background:#0f172a; }
.progress-bar {
position: fixed; top:0; left:0; height:5px; width:100%;
transform-origin: left; /* scale grows from the left edge */
background: linear-gradient(90deg,#ef4444,#f59e0b,#22c55e,#3b82f6);
z-index: 1000;
animation: grow auto linear
...3. Per-Element Reveals โ animation-timeline: view()
scroll() tracks the whole page; sometimes you want an animation tied to one element's trip through the screen. That's animation-timeline: view(). The animation starts as the element enters the viewport and finishes as it leaves, so each card animates itself with no class to toggle.
animation-range fine-tunes when within that journey it plays. animation-range: entry 0% entry 100% means "run from the moment the element starts entering until it has fully entered" โ so the card has finished animating by the time you can see all of it.
Worked example: cards that reveal themselves with view()
Each element drives its own animation as it scrolls into view
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>view() timeline reveals</title>
<style>
body { font-family: system-ui, sans-serif; margin:0; background:#0f172a; color:#e5e7eb; }
.spacer { height:80vh; display:flex; align-items:center; justify-content:center; color:#64748b; }
.card {
max-width:480px; margin:60px auto; padding:28px; border-radius:16px;
background:#1e293b; box-shadow:0 4px 20px rgba(0,0,0,0.3);
animation: rise auto ease-out
...4. Sticky Reveals โ position: sticky
position: sticky is the third scroll trick, and it needs no animation at all. A sticky element scrolls normally until it reaches the offset you set (e.g. top: 0), then it pins in place while the rest of its section scrolls past โ and unpins when the section ends. It's how section headings stay visible and how "scrollytelling" layouts hold an image while text scrolls beside it.
The two rules to remember: a sticky element sticks within its parent (it never escapes the parent's box), and you must give it an offset like top: 0 or it has nothing to stick to.
Worked example: sticky section headings
Headings pin to the top while their section scrolls past
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sticky headings</title>
<style>
body { font-family: system-ui, sans-serif; margin:0; background:#0f172a; color:#e5e7eb; }
section { padding-bottom:40px; }
.sticky-head {
position: sticky; /* scrolls, then pins... */
top: 0; /* ...when it reaches the top of the viewport */
background:#2563eb; color:white;
padding:14px 20px; margin:0; font-size:1.2rem;
...scroll() vs view() vs sticky: scroll() ties an animation to the whole page (0โ100% scrolled). view() ties it to one element entering and leaving the screen. position: sticky isn't an animation at all โ it just pins an element in place during part of the scroll. Reach for the simplest one that does the job.
5. The Cross-Browser Reveal โ IntersectionObserver (JS)
CSS scroll-driven animations are lovely but Chromium-only today, so the workhorse for reveal-on-scroll is a tiny piece of JavaScript: IntersectionObserver. You give it a callback and a threshold (e.g. 0.15 = "fire when 15% of the element is visible"), then tell it which elements to observe. When one crosses the threshold the callback runs, and you add a CSS class โ the CSS transition does the actual animating.
Why not a scroll event listener? Because that fires on every single pixel of movement and runs on the main thread. IntersectionObserver is asynchronous and only calls you at the moment that matters, so it's far smoother. For a one-time reveal, call observer.unobserve(entry.target) after revealing.
Worked example: reveal-on-scroll with IntersectionObserver
JS toggles a class; a CSS transition does the animation
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>IntersectionObserver reveal</title>
<style>
body { font-family: system-ui, sans-serif; margin:0; background:#0f172a; color:#e5e7eb; }
.spacer { height:60vh; display:flex; align-items:center; justify-content:center; color:#64748b; }
/* Start hidden and shifted down. */
.reveal {
opacity:0; transform: translateY(40px);
transition: opacity .6s ease, transform .6s ease; /* CSS does the animatin
...๐ฏ Your Turn #1 โ Make a vertical snap gallery
The container below scrolls vertically but doesn't snap yet. Add the two scroll-snap properties so it locks onto each panel. Fill in the blanks marked ___, then run it and check the expected result in the comments.
Your Turn #1: add scroll snapping
scroll-snap-type on the parent, scroll-snap-align on each child
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your Turn 1</title>
<style>
/* ๐ฏ YOUR TURN โ fill in the blanks marked ___ */
body { margin:0; font-family: system-ui, sans-serif; }
.frame {
height: 320px;
overflow-y: auto; /* this is the scrolling container */
border: 2px solid #334155; border-radius: 12px;
/* 1) Snap vertically, and require landing on a point. */
___ /* ๐ add: scroll-s
...๐ฏ Your Turn #2 โ Wire up an IntersectionObserver reveal
The CSS for a fade-in is ready, but nothing adds the .visible class. Finish the observer so each card reveals as it scrolls in, then verify the expected behaviour in the comments.
Your Turn #2: finish the observer
Add the visible class and observe the cards
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your Turn 2</title>
<style>
body { font-family: system-ui, sans-serif; margin:0; background:#0f172a; color:#e5e7eb; }
.spacer { height:60vh; display:flex; align-items:center; justify-content:center; color:#64748b; }
.reveal {
opacity:0; transform: translateY(40px);
transition: opacity .6s ease, transform .6s ease;
max-width:520px; margin:40px auto; padding:24px; border-radius:12px; backgroun
...๐งฉ Mini-Challenge โ A scroll progress bar from scratch
Support is faded now โ only an outline is given. Build a page with a CSS scroll-driven progress bar, and remember the reduced-motion fallback. Use the worked example in section 2 as your reference if you get stuck.
Mini-Challenge: scroll progress bar
animation-timeline: scroll() + a prefers-reduced-motion fallback
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mini-Challenge</title>
<style>
/* ๐งฉ MINI-CHALLENGE: a reading-progress bar driven by scroll
1. Make a .bar fixed to the top of the page (top:0, left:0,
full width, a few px tall, a bright background, high z-index).
2. Give it: animation: grow auto linear;
animation-timeline: scroll();
(note: duration is "auto" because scroll drives it, not time)
3. Writ
...โ ๏ธ Common Errors (and the fix)
- No reduced-motion fallback. Shipping scroll animations with no
@media (prefers-reduced-motion: reduce)block can make motion-sensitive users feel ill and fails WCAG. Fix: add the media query and inside it setanimation: noneplus the element's final state, so the content is fully usable without movement. - Jank from animating layout properties. Animating
top,left,width, ormarginforces layout and paint on every frame and stutters. Fix: animatetransformandopacityonly โ they run on the GPU compositor. - Expecting animation-timeline to work everywhere. As of 2026,
animation-timeline: scroll()/view()are Chromium-only, so Safari and Firefox simply show no animation. Fix: treat it as progressive enhancement and provide anIntersectionObserverfallback for the effects that must work cross-browser. - Forgetting animation-duration: auto. Leaving a real duration like
2son a scroll-driven animation is a common confusion โ the duration is ignored once a scroll timeline is attached. Fix: set the duration toautoso it reads clearly as scroll-controlled. - Sticky element never sticks.
position: stickywith no offset does nothing, and it can't escape an ancestor withoverflow: hidden. Fix: add an offset liketop: 0and make sure no ancestor clips overflow. - Re-firing reveals / scroll listeners for everything. Without
unobserve()a reveal replays every time you scroll back, and a plainscrolllistener fires on every pixel. Fix: useIntersectionObserverand callobserver.unobserve(entry.target)after a one-time reveal.
๐ Quick Reference
| Goal | Use this |
|---|---|
| Snap a scroll container | scroll-snap-type: x mandatory; |
| Mark a snap point | scroll-snap-align: center; |
| Animate by page scroll | animation-timeline: scroll(); |
| Animate as an element enters | animation-timeline: view(); |
| Tune when it plays | animation-range: entry 0% entry 100%; |
| Pin while scrolling | position: sticky; top: 0; |
| Reveal-on-scroll (all browsers) | new IntersectionObserver(cb, { threshold: 0.15 }) |
| Stop watching after reveal | observer.unobserve(entry.target) |
| Respect motion preferences | @media (prefers-reduced-motion: reduce) { โฆ } |
โ Frequently Asked Questions
What is the difference between scroll() and view() timelines?
Both replace the default time-based clock that drives a CSS animation, but they measure different things. animation-timeline: scroll() maps the animation to how far a scroll container has been scrolled from 0% (top) to 100% (bottom) โ perfect for a page-wide reading-progress bar. animation-timeline: view() maps the animation to a single element's journey through the scrollport: it starts as the element enters the viewport and finishes as it leaves. Use scroll() for whole-page effects and view() for per-element reveals.
Why does my animation-duration get ignored with a scroll timeline?
Because the timeline is no longer a clock. When you set animation-timeline: scroll() (or view()), the animation's progress is driven by scroll position, not elapsed time, so animation-duration has no meaning and you set it to auto. That is why scroll-driven keyframes use auto for the duration โ the scroll bar itself becomes the play head.
Should I use CSS scroll-driven animations or IntersectionObserver?
Use whichever matches your browser-support needs. CSS scroll-driven animations (animation-timeline) are the simplest and smoothest โ they run on the compositor thread โ but as of 2026 they are only in Chromium browsers, so Safari and Firefox see no animation. IntersectionObserver is supported in every modern browser and is the reliable choice for reveal-on-scroll. A robust site does both: progressive-enhancement with CSS where supported, IntersectionObserver as the cross-browser baseline, and a static fallback for prefers-reduced-motion.
Why is my scroll animation janky / stuttering?
Almost always because you are animating a property that forces the browser to recalculate layout or repaint on every frame โ like top, left, width, height, or margin. Animate transform and opacity instead: they run on the GPU compositor and never trigger layout or paint. translateY(40px) is smooth where top: 40px stutters. Reserve will-change for the few elements that genuinely need GPU promotion; adding it everywhere wastes memory.
How do I make a reveal happen only once?
Inside the IntersectionObserver callback, after you add the .visible class, call observer.unobserve(entry.target). That stops the browser watching an element you are done with, so it will not fire again if the user scrolls back up, and it frees resources. Without unobserve, the callback keeps running every time the element re-crosses the threshold.
Do I really need prefers-reduced-motion if the animations are subtle?
Yes. Some people experience nausea, dizziness, or migraines from motion, and the operating-system 'reduce motion' setting is how they ask sites to calm down. Honouring @media (prefers-reduced-motion: reduce) is part of WCAG accessibility guidance, not a nicety. The fix is tiny: in that media query, disable the animations and show the final state immediately, so the content is fully usable without any movement.
๐ Lesson Complete
You can now build modern scroll-based effects and keep them accessible. The essentials:
- โ
scroll-snap-type(parent) +scroll-snap-align(child) lock a gallery onto each item - โ
animation-timeline: scroll()binds an animation to page scroll (durationauto) - โ
animation-timeline: view()+animation-rangeanimate an element as it enters the viewport - โ
position: sticky; top: 0pins an element within its section while you scroll - โ
IntersectionObserveris the cross-browser reveal-on-scroll standard โ toggle a class, let CSS transition - โ Animate transform/opacity, not layout props, to stay jank-free
- โ
Always add
@media (prefers-reduced-motion: reduce)to switch motion off
Next up: Custom Properties Themes, where you'll combine CSS variables with these techniques to build fully themeable, animated interfaces.
Sign up for free to track which lessons you've completed and get learning reminders.