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.
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);}@keyframesgrow-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 | yinset)
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
cover 0% (entry start)
entry 100% / contain 0%
contain 100% / exit 0%
exit 100% / cover 100%
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 */}@keyframesfade-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();}@keyframesreveal{from{height:0;opacity:0;}/* height is non-composited → main thread stutter */to{height:auto;opacity:1;}}/* The composited equivalent */@keyframesreveal{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 */}@keyframesheader-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;}@keyframesparallax{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.