Chapter 5 — Scroll-Driven Animations
Scroll-driven animations let you tie CSS animation playback to scroll position
rather than time. As the user scrolls, the animation advances — no JavaScript event
listeners, no requestAnimationFrame, no scroll handler debouncing.
Because the browser handles playback on the compositor thread, these animations can
run at 60/120 fps without touching the main thread at all, provided you animate
only composited properties (transform and opacity).
The blue–purple progress bar at the top of this page is a live scroll-driven
animation — it is driven by animation-timeline: scroll(root block)
and has exactly zero lines of JavaScript.
Browser support. Scroll-driven animations (the
animation-timeline / scroll() / view() API)
shipped in Chrome/Edge 115 (July 2023) and Safari 18 (September 2024). Firefox
support landed in Firefox 132 (October 2024). All evergreen browsers now support
the full API. Use @supports (animation-timeline: scroll()) to gate
enhanced experiences for older browsers.
1. The Two Timeline Types
scroll() — scroll progress timeline
What it tracks
The scroll position of a scroll container — how far from the top (or left) to the bottom (or right) the scroller has travelled.
Progress: 0% → 100%
0% = scroller at start position. 100% = scroller at end position.
Typical use
Reading progress bars, sticky header effects, parallax that tracks the whole page scroll, any effect that should be proportional to total scroll distance.
animation-timeline: scroll()
view() — view progress timeline
What it tracks
The visibility of an element (the subject) within a scroll container — how far the subject has entered, traversed, and left the visible area.
Progress: 0% → 100%
0% = subject just about to enter. 100% = subject just finished exiting. Controlled by animation-range.
Typical use
Reveal-on-scroll, entrance animations, exit animations, elements that animate while in view.
animation-timeline: view()
2. scroll() — Syntax and Arguments
scroll(
nearest
|
root
|
self
|
element-name
block
|
inline
|
x
|
y
)
scroller argument — which scroll container to track
axis argument — which scroll axis (default: block)
/* scroll() scroller values */
animation-timeline: scroll(); /* nearest — closest scrollable ancestor */
animation-timeline: scroll(nearest); /* same as above — explicit */
animation-timeline: scroll(root); /* the document's root scroller (the page) */
animation-timeline: scroll(self); /* the element itself must be a scroller */
animation-timeline: scroll(--my-scroller); /* named scroll timeline (see Section 5) */
/* scroll() axis values */
animation-timeline: scroll(root block); /* vertical scroll (default axis) */
animation-timeline: scroll(root inline); /* horizontal scroll */
animation-timeline: scroll(root x); /* physical horizontal */
animation-timeline: scroll(root y); /* physical vertical */
/* The reading progress bar pattern */
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--accent);
transform-origin: left; /* scale from the left edge */
animation: grow-bar linear both;
animation-timeline: scroll(root block);
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* animation-fill-mode: both — ensures the bar starts at 0 and stays at 100% */
/* animation-duration is NOT needed for scroll-driven — the timeline replaces time */
/* But you CAN still set a duration if you want to limit the range (advanced) */
3. view() — Syntax and the Subject/Scroller Distinction
view(
block
|
inline
|
x
|
y
inset
)
axis — which axis to track visibility on (default: block)
inset — shrink or expand the "visible" zone (like margin on the viewport)
/* The subject is the element the animation is applied TO */
/* The scroller is the nearest scrollable ancestor of the subject */
/* view() creates a timeline based on the subject's visibility in the scroller */
.card {
animation: fade-in linear both;
animation-timeline: view(); /* subject = .card, scroller = nearest ancestor */
}
/* view() axis — which axis of visibility to track */
animation-timeline: view(block); /* vertical (default) */
animation-timeline: view(inline); /* horizontal */
/* view() inset — shrinks the "visible" zone */
/* Positive inset = zone is smaller than the scroller (triggers earlier) */
/* Negative inset = zone is larger (triggers later) */
animation-timeline: view(block 100px); /* 100px inset all sides */
animation-timeline: view(block 100px 0px); /* 100px start inset, 0 end inset */
animation-timeline: view(block auto); /* auto = use scroll-padding */
/* IMPORTANT: the element must NOT have overflow:hidden on its scroller ancestor */
/* because overflow:hidden blocks the scroll container detection */
/* Use overflow:clip instead if you need to clip without a scroll container */
Subject vs Scroller — the critical distinction
/* The subject is the animated element itself */
/* The scroller is found automatically (nearest scrollable ancestor) */
/* Scenario: animating a card in a scrollable list */
.scroller {
height: 400px;
overflow-y: scroll; /* this is now the view() scroller */
}
.card {
animation: reveal linear both;
animation-timeline: view();
/* subject = .card, scroller = .scroller (nearest scrollable ancestor) */
/* animation tracks .card's visibility WITHIN .scroller, not within the page */
}
/* Scenario: page-level reveal (subject is the card, scroller is the page) */
.card {
animation: reveal linear both;
animation-timeline: view();
/* If no ancestor has overflow:scroll/auto, uses the page scroller */
/* Most reveal-on-scroll patterns fall into this case */
}
/* What view() CANNOT do: animate based on a DIFFERENT element's visibility */
/* To do that, use a named view timeline (Section 5) */
4. animation-range and Range Keywords
animation-range controls which portion of a view timeline maps to
the animation's 0%–100%. Without it, the full view timeline (cover) is used —
the animation runs across the entire time the element is in any part of the
scroll viewport.
viewport / scroller
subject element
cover — entire journey (entering to exiting)
entry — entering the viewport
contain — fully inside the viewport
exit — leaving the viewport
/* animation-range keywords */
/* cover: full timeline — entering to exiting (default) */
animation-range: cover;
animation-range: cover 0% cover 100%; /* same, explicit */
/* entry: element entering the viewport (bottom edge to fully visible) */
animation-range: entry; /* entire entry phase */
animation-range: entry 0% entry 50%; /* first half of entry */
/* exit: element leaving the viewport */
animation-range: exit;
animation-range: exit 0% exit 100%;
/* contain: element fully inside the viewport */
animation-range: contain;
/* Mixing keywords and percentages */
animation-range: entry 0% entry 40%;
/* Animation runs during the first 40% of the entry phase */
/* After that, animation-fill-mode: both keeps it at the 'to' state */
/* Longhand versions */
animation-range-start: entry 0%;
animation-range-end: entry 40%;
/* Percentage-only range (relative to the full cover timeline) */
animation-range: 20% 80%;
/* The reveal-on-scroll pattern */
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 35%;
/* completes the animation while the element is entering — stays visible after */
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: none; }
}
5. Named Timelines
Named timelines let one element's scroll or view progress drive another element's
animation — breaking the default rule that the animated element must be a descendant
of the scroller or subject.
/* ── Named scroll timeline ─────────────────────────────────── */
/* Create a named scroll timeline on the scroller */
.my-scroller {
overflow-y: scroll;
scroll-timeline-name: --my-list; /* custom property name */
scroll-timeline-axis: block; /* which axis */
/* shorthand: scroll-timeline: --my-list block; */
}
/* Reference it from any element in the same subtree or a descendant */
.sidebar-indicator {
animation: track-progress linear both;
animation-timeline: --my-list; /* no scroll() wrapper — just the name */
}
/* ── Named view timeline ─────────────────────────────────────── */
/* Create a named view timeline on the SUBJECT element */
.hero-section {
view-timeline-name: --hero;
view-timeline-axis: block;
/* shorthand: view-timeline: --hero block; */
}
/* Animate a SIBLING element based on the hero section's visibility */
.hero-caption {
animation: caption-fade linear both;
animation-timeline: --hero;
animation-range: contain 0% contain 100%;
/* .hero-caption animates based on .hero-section's visibility, not its own */
}
/* Named timelines are scoped to the timeline-scope property */
/* By default a named timeline is visible to all descendants */
/* timeline-scope: --hero makes it available to elements OUTSIDE the subject */
.page-layout {
timeline-scope: --hero; /* allows siblings of .hero-section to use --hero */
}
6. Live Demos
Reveal on scroll — view() with animation-range: entry
Cards animate in as they enter the viewport — scroll to trigger
Section AFades up as it enters. animation-timeline: view(); animation-range: entry 0% entry 40%;
Section BSame animation — each element has its own independent view timeline based on its own position.
Section Canimation-fill-mode: both keeps the element visible once the entry phase is past.
Parallax — view() with animation-range: cover
Background layer moves slower than the scroll — classic parallax
Parallax background — view() cover range — translateY(-20px) → translateY(20px)
Staggered reveal — view() with animation-delay
List items reveal in sequence using CSS custom-property stagger
- First item — animation-delay: 0ms
- Second item — animation-delay: 60ms
- Third item — animation-delay: 120ms
- Fourth item — animation-delay: 180ms
/* ── Stagger with CSS custom property index ──────────────────── */
.list-item {
animation: slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
animation-delay: calc(var(--index, 0) * 60ms);
}
/* Set --index in HTML or with :nth-child */
.list-item:nth-child(1) { --index: 0; }
.list-item:nth-child(2) { --index: 1; }
.list-item:nth-child(3) { --index: 2; }
/* Or set style="--index: N" in your template */
7. Compositing — Which Properties Are Safe
Scroll-driven animations run on the compositor thread only if the animated
properties are composited. Composited properties skip layout and
paint entirely — the GPU handles them. Any non-composited property forces the
animation back to the main thread, where it can stutter under scroll load.
| Property | Composited | Why / consequence |
| transform |
Yes |
GPU layer — translate, scale, rotate, skew all safe. The primary composited property. |
| opacity |
Yes |
GPU layer — completely safe. Combine with transform for most reveal patterns. |
| filter |
Mostly |
blur(), brightness() etc. are composited in Chrome/Edge. May paint in Firefox. |
| clip-path |
Mostly |
Simple shapes composited in modern browsers. Complex paths may repaint. |
| width / height |
No |
Triggers layout → paint → composite. Will jank under scroll. Use transform: scaleX/scaleY instead. |
| background-color |
No |
Triggers repaint every frame. Use opacity on a pseudo-element overlay instead. |
| color / font-size |
No |
Layout and paint on every frame. Only use if the effect requires it and performance is acceptable. |
| left / top / margin |
No |
Triggers layout. Use transform: translate() for positioning effects. |
/* ── will-change with scroll-driven animations ────────────────── */
/* For elements that use view() and enter/exit frequently */
.reveal-card {
will-change: transform, opacity;
/* Pre-promotes the element to its own GPU layer */
/* Only add when you have measured a benefit — excess GPU layers waste VRAM */
}
/* For the progress bar (always animating while page scrolls) */
.progress-bar {
will-change: transform; /* reasonable — it's always in motion */
}
/* DO NOT do this — it defeats compositing for the scroll-driven animation */
.card {
animation: reveal linear both;
animation-timeline: view();
}
@keyframes reveal {
from { height: 0; opacity: 0; } /* height is non-composited → main thread stutter */
to { height: auto; opacity: 1; }
}
/* The composited equivalent */
@keyframes reveal {
from { transform: scaleY(0); opacity: 0; }
to { transform: none; opacity: 1; }
}
8. Production Patterns
/* ── Sticky header that fades in a background on scroll ──────── */
.site-header {
position: sticky;
top: 0;
animation: header-bg linear both;
animation-timeline: scroll(root block);
animation-range: 0px 120px; /* first 120px of scroll */
}
@keyframes header-bg {
from { background: transparent; backdrop-filter: blur(0px); }
to { background: rgba(13,17,23,0.85); backdrop-filter: blur(12px); }
}
/* ── Horizontal scroll gallery ───────────────────────────────── */
.gallery {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-timeline: --gallery inline;
}
.gallery__indicator {
/* Dot that fills as the gallery scrolls right */
animation: fill-dot linear both;
animation-timeline: --gallery;
}
/* ── Parallax hero ───────────────────────────────────────────── */
.hero {
view-timeline: --hero block;
overflow: hidden;
}
.hero__bg {
position: absolute;
inset: -15% 0; /* extra height for parallax room */
animation: parallax linear both;
animation-timeline: --hero;
animation-range: cover;
}
@keyframes parallax {
from { transform: translateY(-15%); }
to { transform: translateY(15%); }
}
/* ── prefers-reduced-motion reset ────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
/* Remove all scroll-driven animations cleanly */
*, *::before, *::after {
animation-timeline: auto !important; /* fall back to time-based */
animation-duration: 0.01ms !important; /* instant — preserves fill-mode */
animation-iteration-count: 1 !important;
}
}
/* ── Feature detection ────────────────────────────────────────── */
@supports (animation-timeline: scroll()) {
.progress-bar {
animation-timeline: scroll(root block);
}
}
/* Without @supports, provide a static fallback (no bar, or JS-based bar) */
9. animation-timeline Interaction with Other Animation Properties
/* animation-duration: does it matter? */
/* For scroll-driven animations, duration is IGNORED for playback rate */
/* The timeline (scroll position) drives playback, not time */
/* But duration IS used if you also set animation-delay in time units */
/* Best practice: omit or set to 'auto' for scroll-driven animations */
animation-duration: auto; /* explicit no-time — clearer than omitting */
/* animation-fill-mode: both is almost always what you want */
/* 'both' = apply 'from' state before the range starts, 'to' state after it ends */
/* Without 'both', elements snap to unstyled state before/after their range */
animation-fill-mode: both; /* nearly always required */
/* animation-play-state — works with scroll-driven */
/* pause it programmatically: element.style.animationPlayState = 'paused' */
/* Note: pausing freezes the animation at current scroll position */
/* animation-iteration-count: only '1' makes sense for scroll-driven */
/* 'infinite' would restart the animation each time progress hits 100% */
/* which never happens during scroll — so infinite loops don't apply here */
/* animation-direction: alternate DOES work interestingly with scroll: */
/* as the user scrolls down, animation plays forward;
as they scroll up, it reverses — naturally undoing the effect */
/* This is the default behaviour of all scroll-driven animations
(scroll position drives progress in both directions) */
/* Combining time-based and scroll-driven on the same element */
.card {
/* Time-based entrance (runs once on page load) */
animation: fade-in 0.4s ease both,
reveal 1s linear both;
/* Only the second animation uses a scroll timeline */
animation-timeline: auto, view();
animation-range: normal, entry 0% entry 40%;
}
Chapter Summary
| Concept | Key point |
| scroll() | Tracks a scroll container's position. Arguments: scroller (nearest/root/self/name) and axis (block/inline/x/y). 0% = start, 100% = end of scroll range. |
| view() | Tracks subject visibility in a scroller. Arguments: axis and inset. Subject = the animated element. Scroller = nearest scrollable ancestor. Named timelines break this constraint. |
| animation-range | Maps a portion of the timeline to the animation's 0–100%. Keywords: cover (default), entry, exit, contain. Mix with percentages: entry 0% entry 40%. |
| Named timelines | scroll-timeline-name / view-timeline-name create named timelines on scroller/subject. Reference by name on any element. timeline-scope expands visibility to non-descendants. |
| animation-fill-mode | Always set both for scroll-driven animations. Without it, elements snap to unstyled state before/after their animation range. |
| Compositing | Only transform and opacity are guaranteed composited (GPU thread). Animating width, height, color, or background on a scroll timeline causes main-thread jank. |
| Reverse on scroll-up | Scroll-driven animations automatically reverse when the user scrolls back. This is often desirable — plan animation-range so the reversed state looks correct. |
| prefers-reduced-motion | Use @media (prefers-reduced-motion: reduce) to reset animation-timeline: auto and collapse duration to 0.01ms. This jumps to the final keyframe state rather than hiding content. |
| Feature detection | @supports (animation-timeline: scroll()) gates the enhanced experience. Provide a static fallback for browsers without support. |
Exercises
- Reading progress bar: Add a fixed reading progress bar to a long-form page using
scroll(root block). Use transform: scaleX() (not width) animated from 0 to 1 with transform-origin: left. Add a gradient fill and verify the bar correctly reaches 100% only at the very bottom of the page content, not the viewport bottom. Then add a prefers-reduced-motion block that hides the bar entirely (opacity: 0 on the bar rather than removing the animation).
- Reveal-on-scroll grid: Create a grid of at least 6 cards. Apply a
view() timeline with animation-range: entry 0% entry 35% to each card for a fade-up entrance. Add a stagger using :nth-child() selectors to set --index on each card and use calc(var(--index) * 80ms) for animation-delay. Verify that scrolling back up plays the reveal in reverse. Then add a prefers-reduced-motion reset and confirm cards are visible without any animation.
- Named view timeline — cross-element animation: Create a
.hero section that sets view-timeline: --hero block. Create a separate .hero-badge element that is a sibling (not a descendant) of .hero. Apply timeline-scope: --hero on their common ancestor. Animate the badge's opacity and transform based on --hero's visibility — it should fade out as the hero leaves the viewport. Confirm it does not use view() directly (which would track the badge's own visibility).
- Horizontal scroll indicator: Build a horizontally scrolling card carousel with
scroll-snap-type: x mandatory. Create a named scroll timeline on the carousel with scroll-timeline: --carousel inline. Drive a set of indicator dots below the carousel: animate each dot's scale and opacity using --carousel at different animation-range percentage slices so each dot is active when its corresponding card is visible. No JavaScript.
- Performance audit: Build a scroll-driven animation that deliberately animates a non-composited property (e.g.
background-color or font-size). Open Chrome DevTools → Performance panel, record while scrolling, and identify the paint events triggered by each scroll frame. Then refactor to use only transform and opacity — record again and confirm paint events disappear. Document the before/after frame time difference.
Next: Chapter 6 — Logical Properties and Writing Modes.
Physical vs logical property model — how inline-size, block-size, margin-inline,
padding-block, border-start, and inset replace their physical counterparts.
Writing modes (horizontal-tb, vertical-rl, vertical-lr, sideways-rl), text
directionality, and building genuinely internationalised layouts without
per-language CSS overrides.