Scroll-Driven Animations

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
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 */ } @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.

PropertyCompositedWhy / 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

ConceptKey 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-rangeMaps 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 timelinesscroll-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-modeAlways set both for scroll-driven animations. Without it, elements snap to unstyled state before/after their animation range.
CompositingOnly 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-upScroll-driven animations automatically reverse when the user scrolls back. This is often desirable — plan animation-range so the reversed state looks correct.
prefers-reduced-motionUse @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
  1. 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).
  2. 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.
  3. 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).
  4. 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.
  5. 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.