{"id":"l-css-4-3","courseId":"css-mastery","lang":"en","kind":"lesson","summary":"Drive CSS animations from scroll position with scroll() and view() timelines, choose animation ranges, and ship them as a progressive enhancement while Firefox support is pending.","html":"<p>Waypoint’s schedule is long: two days, four tracks, sixty sessions. The team wants two small touches: a thin progress bar under the header showing how far down the schedule you are, and speaker cards that ease into view as you scroll to them. The old way was a <code dir=\"ltr\">scroll</code> event listener that read <code dir=\"ltr\">scrollY</code>, did some arithmetic and wrote styles, sixty times a second, on the main thread, competing with everything else.</p>\n<p>Scroll-driven animations let CSS do it. You write a normal <code dir=\"ltr\">@keyframes</code> animation and swap its clock: instead of time, its progress comes from scrolling.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Two kinds of timeline</h2>\n<p><strong>A scroll progress timeline</strong>, <code dir=\"ltr\">scroll()</code>, goes from 0% to 100% as a scroll container scrolls from top to bottom. <code dir=\"ltr\">scroll(root)</code> is the document; <code dir=\"ltr\">scroll(nearest)</code>, the default, is the nearest scrolling ancestor.</p>\n<p><strong>A view progress timeline</strong>, <code dir=\"ltr\">view()</code>, follows one element as it travels through its scroll container’s visible area (the scrollport): from the moment its edge peeks in at the bottom to the moment it leaves at the top.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">progress.css</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@keyframes</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> grow</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">scale</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  to</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">   { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">scale</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.progress</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  position</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">fixed</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  inset</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> auto</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  height</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">3</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">px</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  background</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--track-design</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  transform-origin</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 50</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">%</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: grow </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">linear</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> both</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-timeline</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">scroll</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(root);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>At the top of the page the bar is scaled to zero; at the bottom it’s full width; anywhere in between it’s exactly proportional. <code dir=\"ltr\">linear</code> matters here: with the default <code dir=\"ltr\">ease</code>, the bar would race ahead in the middle and crawl at the ends. <code dir=\"ltr\">both</code> keeps the first and last keyframes applied outside the range.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Declaring the timeline before the shorthand</span></p>\n<p>The <code dir=\"ltr\">animation</code> shorthand resets every animation longhand, including <code dir=\"ltr\">animation-timeline</code>, back to its default, <code dir=\"ltr\">auto</code>. Write <code dir=\"ltr\">animation-timeline</code> <em>after</em> <code dir=\"ltr\">animation</code>, or the animation silently becomes a time-based one with zero duration, and the bar never moves.</p>\n</aside>\n<h2 id=\"sec-2\" tabindex=\"-1\">Reveal on view, with ranges</h2>\n<p>For the speaker cards, a view timeline is the right clock, and <code dir=\"ltr\">animation-range</code> chooses which part of the card’s path runs the animation:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">speakers.css</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@keyframes</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> reveal</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">opacity</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">translate</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 24</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">px</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  to</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">   { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">opacity</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">translate</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.speaker</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: reveal </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">linear</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> both</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-timeline</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: view();</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-range</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: entry </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">%</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> cover</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 30</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">%</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 260\" role=\"img\" aria-labelledby=\"l-css-4-3-t1\"><defs><marker id=\"arrow-l-css-4-3-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-css-4-3-t1\">A tall scrollport rectangle. A card is shown at three positions: just entering at the bottom edge, fully inside, and leaving at the top edge. Labels mark the entry range at the bottom edge, the contain range while fully inside, and the exit range at the top edge; cover spans from first entry to final exit.</title>\n  <rect class=\"d-box-accent d-dashed\" x=\"200\" y=\"40\" width=\"240\" height=\"180\" rx=\"8\"/>\n  <text class=\"d-label-muted\" x=\"210\" y=\"60\">scrollport</text>\n  <rect class=\"d-box-primary\" x=\"250\" y=\"200\" width=\"140\" height=\"44\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"290\" y=\"227\">card</text>\n  <rect class=\"d-box-primary\" x=\"250\" y=\"110\" width=\"140\" height=\"44\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"290\" y=\"137\">card</text>\n  <rect class=\"d-box-primary\" x=\"250\" y=\"18\" width=\"140\" height=\"44\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"290\" y=\"45\">card</text>\n  <text class=\"d-code\" x=\"460\" y=\"226\">entry</text>\n  <text class=\"d-code\" x=\"460\" y=\"138\">contain</text>\n  <text class=\"d-code\" x=\"460\" y=\"46\">exit</text>\n  <path class=\"d-line\" d=\"M150 244 L150 18\"/>\n  <text class=\"d-code\" x=\"60\" y=\"136\">cover</text>\n  <path class=\"d-arrow\" d=\"M600 236 L600 30\" marker-end=\"url(#arrow-l-css-4-3-1)\"/>\n  <text class=\"d-label-muted\" x=\"560\" y=\"254\">scrolling down</text>\n</svg><figcaption>A view timeline follows an element through the scrollport; named ranges mark parts of that path</figcaption></figure>\n<p>The named ranges describe the element’s path: <strong>entry</strong> while it crosses the bottom edge, <strong>contain</strong> while it’s fully inside (or fully covering, if it’s taller), <strong>exit</strong> while it crosses the top edge, and <strong>cover</strong> for the whole trip from first pixel in to last pixel out. <code dir=\"ltr\">entry 0% cover 30%</code> means “start as the card begins to enter, finish when it’s 30% through its whole trip”, so each card is fully visible well before the user reaches it.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Ship it as an enhancement</h2>\n<p>Here’s the support picture in October 2026: scroll-driven animations have been in Chromium since version 115 (2023) and in Safari since 26 (September 2025). Firefox has an implementation behind a flag, and it’s an Interop 2026 focus area, but stable Firefox doesn’t support it yet, so the feature isn’t Baseline.</p>\n<p>That shapes how you write it. The reveal keyframes start at <code dir=\"ltr\">opacity: 0</code>, and in a browser that understands <code dir=\"ltr\">animation-timeline</code>, that’s fine. In one that doesn’t, <code dir=\"ltr\">animation: reveal linear both</code> with no timeline is a zero-duration time animation that ends at its last keyframe, so the cards still show. Don’t rely on that subtlety; make the intent explicit:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@supports</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">animation-timeline</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: view()) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  @media</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (prefers-reduced-motion: no-preference) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    .speaker</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: reveal </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">linear</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> both</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      animation-timeline</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: view();</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      animation-range</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: entry </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">%</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> cover</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 30</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">%</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Unsupported browsers and reduced-motion users get the plain, fully visible grid. Supported browsers get the reveal.</p>\n<aside class=\"callout callout-why\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><circle cx=\"12\" cy=\"12\" r=\"6\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/></svg><span><span class=\"callout-kind\">Why this matters</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Why this beats a scroll listener</span></p>\n<p>A scroll-driven animation of <code dir=\"ltr\">opacity</code>, <code dir=\"ltr\">translate</code> or <code dir=\"ltr\">scale</code> can run on the compositor, in sync with scrolling, even while the main thread is busy running your JavaScript. A scroll listener runs on the main thread and is always a frame behind. The CSS version is shorter, smoother and has no code to clean up.</p>\n</aside>\n<p>You can also name timelines (<code dir=\"ltr\">view-timeline-name: --speaker</code>) so another element can follow them, and use <code dir=\"ltr\">timeline-scope</code> to make a name visible higher in the tree. Reach for those when one element’s scroll position should drive a <em>different</em> element; for self-contained effects, anonymous <code dir=\"ltr\">scroll()</code> and <code dir=\"ltr\">view()</code> are enough.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Your turn</h2>\n<p>In the exercise, add the reading progress bar and the speaker reveal, in the right order, guarded by <code dir=\"ltr\">@supports</code>. The checks read the computed timelines and ranges. Next, you’ll step back from individual effects and organise the whole stylesheet so a team can work on it.</p>\n","toc":[{"id":"sec-1","text":"Two kinds of timeline","level":2},{"id":"sec-2","text":"Reveal on view, with ranges","level":2},{"id":"sec-3","text":"Ship it as an enhancement","level":2},{"id":"sec-4","text":"Your turn","level":2}],"takeaways":["<code dir=\"ltr\">animation-timeline: scroll()</code> ties an animation’s progress to how far a scroll container has scrolled; <code dir=\"ltr\">view()</code> ties it to an element’s passage through the visible area.","<code dir=\"ltr\">animation-range</code> picks which part of the timeline plays the animation, for example <code dir=\"ltr\">entry 0% cover 30%</code> for a reveal that finishes soon after the element appears.","The <code dir=\"ltr\">animation</code> shorthand resets <code dir=\"ltr\">animation-timeline</code>, so declare the timeline after the shorthand.","Scroll-driven animations work in Chromium and Safari 26 but not yet in stable Firefox, so wrap them in <code dir=\"ltr\">@supports</code> and make sure content is fully visible without them."],"further":[{"title":"Scroll-driven animation timelines (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timelines"},{"title":"animation-timeline (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-timeline"},{"title":"animation-range (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-range"}],"quiz":[{"q":"<p>The progress bar never moves. Why?</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.progress</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-timeline</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">scroll</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(root);</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: grow </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">linear</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> both</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n","options":[{"text":"<code dir=\"ltr\">scroll(root)</code> isn’t valid; it should be <code dir=\"ltr\">scroll(html)</code>.","why":"<code dir=\"ltr\">root</code> is the correct keyword for the document’s scroller; <code dir=\"ltr\">html</code> isn’t accepted there."},{"text":"The <code dir=\"ltr\">animation</code> shorthand comes second and resets <code dir=\"ltr\">animation-timeline</code> to <code dir=\"ltr\">auto</code>, so the animation runs on time with no duration.","why":"Correct. The shorthand resets every animation longhand, including the timeline. Put <code dir=\"ltr\">animation-timeline</code> after it."},{"text":"Scroll-driven animations need <code dir=\"ltr\">animation-duration: 1s</code>.","why":"Duration doesn’t apply to scroll timelines; progress comes from scroll position. <code dir=\"ltr\">auto</code> is fine."}],"answer":1},{"q":"<p>A speaker card should fade in as it enters the viewport and be fully visible by the time 30% of it is showing. Which declarations?</p>","options":[{"text":"<code dir=\"ltr\">animation-timeline: scroll(); animation-range: 0% 30%;</code>","why":"A <code dir=\"ltr\">scroll()</code> timeline tracks the whole page’s scroll position, so the range would refer to the first 30% of the page, not to the card."},{"text":"<code dir=\"ltr\">animation-timeline: view(); animation-range: exit 0% exit 30%;</code>","why":"The exit range is when the card is leaving the viewport, which is the opposite end of its path."},{"text":"<code dir=\"ltr\">animation-timeline: view(); animation-range: entry 0% cover 30%;</code>","why":"Correct. <code dir=\"ltr\">view()</code> tracks the card’s visibility, and the range starts as it begins to enter and ends 30% into its full cover range."}],"answer":2},{"q":"<p>In 2026, a teammate wants to make the speaker grid hidden until scroll-driven reveals fade each card in. What’s the main risk?</p>","options":[{"text":"In browsers without scroll timelines, such as stable Firefox, a card that starts at opacity 0 may never become visible.","why":"Correct. Scroll-driven animations aren’t Baseline yet. Put the hidden starting state inside <code dir=\"ltr\">@supports (animation-timeline: view())</code> so unsupported browsers show the cards normally."},{"text":"Scroll-driven animations always block the main thread and cause jank.","why":"Animations of compositor-friendly properties like opacity and transform can run off the main thread; that’s one of their advantages over scroll listeners."},{"text":"Screen readers announce every frame of the animation.","why":"Screen readers don’t announce visual animation frames."},{"text":"Each card needs its own named timeline.","why":"An anonymous <code dir=\"ltr\">view()</code> timeline is per element automatically; names are only needed to share a timeline between elements."}],"answer":0}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<div class=\"progress\" aria-hidden=\"true\"></div>\n<h2>Speakers</h2>\n<section class=\"speakers\">\n  <article class=\"speaker\">Ana Ortiz</article>\n  <article class=\"speaker\">Kenji Watanabe</article>\n  <article class=\"speaker\">Layla Haddad</article>\n  <article class=\"speaker\">Tom Becker</article>\n  <article class=\"speaker\">Priya Nair</article>\n  <article class=\"speaker\">Marcus Lee</article>\n</section>","css":".progress {\n  position: fixed;\n  inset: 0 0 auto;\n  height: 4px;\n  background: #6d28d9;\n  transform-origin: 0 50%;\n}\n.speakers { display: grid; gap: 16px; }\n.speaker {\n  padding: 40px 16px;\n  border-radius: 12px;\n  background: #f5f3ff;\n}\n\n@keyframes grow {\n  from { scale: 0 1; }\n  to { scale: 1 1; }\n}\n@keyframes reveal {\n  from { opacity: 0; translate: 0 24px; }\n  to { opacity: 1; translate: 0 0; }\n}\n\n/* A first attempt: why doesn't the bar move? */\n.progress {\n  animation-timeline: scroll(root);\n  animation: grow linear both;\n}\n"},"solution":{"html":"<div class=\"progress\" aria-hidden=\"true\"></div>\n<h2>Speakers</h2>\n<section class=\"speakers\">\n  <article class=\"speaker\">Ana Ortiz</article>\n  <article class=\"speaker\">Kenji Watanabe</article>\n  <article class=\"speaker\">Layla Haddad</article>\n  <article class=\"speaker\">Tom Becker</article>\n  <article class=\"speaker\">Priya Nair</article>\n  <article class=\"speaker\">Marcus Lee</article>\n</section>","css":".progress {\n  position: fixed;\n  inset: 0 0 auto;\n  height: 4px;\n  background: #6d28d9;\n  transform-origin: 0 50%;\n}\n.speakers { display: grid; gap: 16px; }\n.speaker {\n  padding: 40px 16px;\n  border-radius: 12px;\n  background: #f5f3ff;\n}\n\n@keyframes grow {\n  from { scale: 0 1; }\n  to { scale: 1 1; }\n}\n@keyframes reveal {\n  from { opacity: 0; translate: 0 24px; }\n  to { opacity: 1; translate: 0 0; }\n}\n\n@supports (animation-timeline: view()) {\n  @media (prefers-reduced-motion: no-preference) {\n    .progress {\n      animation: grow linear both;\n      animation-timeline: scroll(root);\n    }\n    .speaker {\n      animation: reveal linear both;\n      animation-timeline: view();\n      animation-range: entry 0% cover 30%;\n    }\n  }\n}\n"},"checks":[{"id":"progress-scroll-timeline","test":"const src = [...document.querySelectorAll('style')].map((s) => s.textContent).join('\\n'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !CSS.supports('animation-timeline', 'view()'); if (reduced) return /animation-timeline:\\s*scroll\\(/.test(src); return style('.progress', 'animation-timeline').startsWith('scroll(') && style('.progress', 'animation-name') === 'grow';"},{"id":"progress-linear","test":"const src = [...document.querySelectorAll('style')].map((s) => s.textContent).join('\\n'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !CSS.supports('animation-timeline', 'view()'); if (reduced) return /grow\\s+linear/.test(src); return style('.progress', 'animation-timing-function') === 'linear' && ['both', 'forwards'].includes(style('.progress', 'animation-fill-mode'));"},{"id":"speaker-view-timeline","test":"const src = [...document.querySelectorAll('style')].map((s) => s.textContent).join('\\n'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !CSS.supports('animation-timeline', 'view()'); if (reduced) return /animation-timeline:\\s*view\\(/.test(src); return $$('.speaker').every((el) => style(el, 'animation-timeline').startsWith('view(') && style(el, 'animation-name') === 'reveal');"},{"id":"speaker-range","test":"const src = [...document.querySelectorAll('style')].map((s) => s.textContent).join('\\n'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !CSS.supports('animation-timeline', 'view()'); if (reduced) return /animation-range:\\s*entry/.test(src); return style('.speaker', 'animation-range-start').startsWith('entry') && style('.speaker', 'animation-range-end') === 'cover 30%';"},{"id":"guarded","test":"const src = [...document.querySelectorAll('style')].map((s) => s.textContent).join('\\n'); if (!CSS.supports('animation-timeline', 'view()')) return /@supports\\s*\\(\\s*animation-timeline/.test(src); const all = []; const walk = (rules, ctx) => { for (const r of rules) { all.push({ r, ctx }); if (r.cssRules) walk(r.cssRules, ctx + ' ' + (r.conditionText || '')); } }; for (const s of document.styleSheets) walk(s.cssRules, ''); return all.some(({ r, ctx }) => r.selectorText === '.speaker' && /view\\(/.test(r.cssText) && ctx.includes('animation-timeline'));"}],"title":"Scroll-driven progress and reveals","prompt":"<p>The <code dir=\"ltr\">grow</code> and <code dir=\"ltr\">reveal</code> keyframes are ready. Wire them to scrolling:</p>\n<ol>\n<li>Fix the progress bar so <code dir=\"ltr\">grow</code> runs on the <strong>document’s scroll timeline</strong>, with <code dir=\"ltr\">linear</code> timing and a fill mode that holds the keyframes (<code dir=\"ltr\">both</code>).</li>\n<li>Make each <code dir=\"ltr\">.speaker</code> run <code dir=\"ltr\">reveal</code> on its <strong>view timeline</strong>, from the start of its <strong>entry</strong> to <strong>30% of its cover</strong> range.</li>\n<li>Put the scroll-driven rules inside <code dir=\"ltr\">@supports (animation-timeline: view())</code> so browsers without support show the plain page. (Wrapping them in <code dir=\"ltr\">prefers-reduced-motion: no-preference</code> too is recommended.)</li>\n</ol>\n","hints":["Look at the order in the starter: which declaration resets the other?","For the speakers: <code dir=\"ltr\">animation: reveal linear both;</code> then <code dir=\"ltr\">animation-timeline: view();</code> and <code dir=\"ltr\">animation-range: …</code>.","<code dir=\"ltr\">@supports (animation-timeline: view()) { .progress { animation: grow linear both; animation-timeline: scroll(root); } .speaker { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% cover 30%; } }</code>"],"explanation":"<p>The starter’s bar never moved because the <code dir=\"ltr\">animation</code> shorthand came after <code dir=\"ltr\">animation-timeline</code> and reset it to <code dir=\"ltr\">auto</code>, leaving a zero-length time animation. Declaring the timeline after the shorthand ties <code dir=\"ltr\">grow</code> to the root scroller, and <code dir=\"ltr\">linear</code> keeps the bar proportional to scroll distance. Each speaker’s anonymous <code dir=\"ltr\">view()</code> timeline tracks that card through the viewport, and <code dir=\"ltr\">entry 0% cover 30%</code> makes the reveal finish shortly after the card appears. The <code dir=\"ltr\">@supports</code> guard keeps the reveal out of browsers that can’t run it, such as stable Firefox in 2026, where the cards simply stay visible.</p>\n","checkLabels":{"progress-scroll-timeline":"The bar’s animation runs on a scroll() timeline","progress-linear":"The bar uses linear timing and keeps its keyframes","speaker-view-timeline":"Every speaker card runs reveal on a view() timeline","speaker-range":"The reveal runs from entry to cover 30%","guarded":"The rules are inside @supports (animation-timeline: …)"}},"words":680,"readMinutes":3}