Skip to main content

    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

    Build a scroll-snap gallery that locks onto each item with scroll-snap-type and scroll-snap-align
    Drive a CSS animation from scroll position with animation-timeline: scroll() and @keyframes
    Reveal an element as it enters the viewport with animation-timeline: view()
    Pin a heading while content scrolls past using position: sticky
    Reveal-on-scroll across every browser with the JavaScript IntersectionObserver pattern
    Switch every animation off with @media (prefers-reduced-motion: reduce)
    Before this lesson: you should be comfortable writing CSS rules, using 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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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

    Try it Yourself ยป
    Code Preview
    <!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 set animation: none plus the element's final state, so the content is fully usable without movement.
    • Jank from animating layout properties. Animating top, left, width, or margin forces layout and paint on every frame and stutters. Fix: animate transform and opacity only โ€” 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 an IntersectionObserver fallback for the effects that must work cross-browser.
    • Forgetting animation-duration: auto. Leaving a real duration like 2s on a scroll-driven animation is a common confusion โ€” the duration is ignored once a scroll timeline is attached. Fix: set the duration to auto so it reads clearly as scroll-controlled.
    • Sticky element never sticks. position: sticky with no offset does nothing, and it can't escape an ancestor with overflow: hidden. Fix: add an offset like top: 0 and 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 plain scroll listener fires on every pixel. Fix: use IntersectionObserver and call observer.unobserve(entry.target) after a one-time reveal.

    ๐Ÿ“‹ Quick Reference

    GoalUse this
    Snap a scroll containerscroll-snap-type: x mandatory;
    Mark a snap pointscroll-snap-align: center;
    Animate by page scrollanimation-timeline: scroll();
    Animate as an element entersanimation-timeline: view();
    Tune when it playsanimation-range: entry 0% entry 100%;
    Pin while scrollingposition: sticky; top: 0;
    Reveal-on-scroll (all browsers)new IntersectionObserver(cb, { threshold: 0.15 })
    Stop watching after revealobserver.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 (duration auto)
    • โœ… animation-timeline: view() + animation-range animate an element as it enters the viewport
    • โœ… position: sticky; top: 0 pins an element within its section while you scroll
    • โœ… IntersectionObserver is 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy Policy โ€ข Terms of Service