Scroll Animations
Scroll-based animations tie an element's movement, fade, or progress to the user's scroll position, using tools like CSS scroll snapping, scroll-driven animations, and the JavaScript IntersectionObserver to reveal content as it enters the viewport.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free HTML & CSS course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
You can now build modern scroll-based effects and keep them accessible. The essentials:
Next up: Custom Properties Themes , where you'll combine CSS variables with these techniques to build fully themeable, animated interfaces.
Practice quiz
Which two properties set up CSS scroll snapping?
- scroll-behavior and scroll-margin
- overflow and snap-to
- scroll-snap-type on the container and scroll-snap-align on each child
- scroll-snap on both
Answer: scroll-snap-type on the container and scroll-snap-align on each child. Put scroll-snap-type (e.g. x mandatory) on the scrolling container and scroll-snap-align (e.g. center) on each child you want to be a snap point.
What does 'scroll-snap-type: x mandatory' mean?
- Snap along the x axis, and the scroll must land on a snap point
- Snap vertically, optionally
- Disable snapping on x
- Snap only on touch devices
Answer: Snap along the x axis, and the scroll must land on a snap point. x is the axis; mandatory forces the scroll to rest on a snap point (use proximity instead to only snap when released near one).
What binds a CSS animation to the page's scroll position?
- animation-delay: scroll
- scroll-animation: page
- animation-play-state: scroll
- animation-timeline: scroll()
Answer: animation-timeline: scroll(). animation-timeline: scroll() replaces the time-based clock with the scroll container's progress (0% at top, 100% at bottom).
Why is animation-duration set to 'auto' with a scroll timeline?
- To make it infinite
- Because scroll position, not elapsed time, drives the progress — duration is meaningless
- Because auto means 1 second
- It's a syntax requirement of @keyframes
Answer: Because scroll position, not elapsed time, drives the progress — duration is meaningless. Once a scroll timeline drives the animation, time no longer controls it, so the duration is set to auto — the scrollbar becomes the play head.
What does 'animation-timeline: view()' tie the animation to?
- A single element's journey through the viewport (enters and leaves)
- The whole page's scroll
- The browser zoom level
- A fixed 3-second timer
Answer: A single element's journey through the viewport (enters and leaves). view() maps the animation to one element's trip through the scrollport — starting as it enters and finishing as it leaves — ideal for per-element reveals.
Why animate transform and opacity rather than top, left, or width for scroll effects?
- They are easier to type
- Other properties are deprecated
- transform/opacity run on the GPU compositor and don't trigger layout or paint, so they stay smooth
- They animate slower on purpose
Answer: transform/opacity run on the GPU compositor and don't trigger layout or paint, so they stay smooth. Animating top/left/width/margin forces layout and paint every frame (jank). transform and opacity are composited on the GPU and stay smooth.
Why use IntersectionObserver instead of a scroll event listener for reveals?
- It is older
- It is asynchronous and only fires at the threshold moment, instead of on every pixel on the main thread
- It animates by itself
- Scroll events don't work on mobile
Answer: It is asynchronous and only fires at the threshold moment, instead of on every pixel on the main thread. A scroll listener fires on every pixel of movement on the main thread; IntersectionObserver is async and only calls you when an element crosses the threshold.
How do you make an IntersectionObserver reveal happen only once?
- Set threshold to 1
- Remove the element from the DOM
- Use a CSS animation instead
- Call observer.unobserve(entry.target) after adding the visible class
Answer: Call observer.unobserve(entry.target) after adding the visible class. Calling observer.unobserve(entry.target) after revealing stops the browser watching it, so it won't re-fire if the user scrolls back up.
What does position: sticky need in order to actually pin?
- A fixed parent
- An offset such as top: 0, and no ancestor that clips overflow
- display: sticky
- A high z-index only
Answer: An offset such as top: 0, and no ancestor that clips overflow. A sticky element needs a threshold like top: 0 to stick to, and it sticks within its parent; an ancestor with overflow: hidden/auto/scroll breaks it.
Why is a @media (prefers-reduced-motion: reduce) block required for scroll animations?
- It speeds up the page
- It is needed for the animation to run
- Some users get nausea or migraines from motion; honouring the setting is WCAG accessibility guidance
- It only affects print styles
Answer: Some users get nausea or migraines from motion; honouring the setting is WCAG accessibility guidance. Motion can make some users ill, so respecting the OS 'reduce motion' setting (disable animation, show the final state) is part of WCAG.