Scroll-driven animations link an ordinary CSS animation to scrolling instead of to the clock. Set animation-timeline to scroll() and the animation follows how far a container has been scrolled; set it to view() and it follows an element's journey through the visible area. Progress bars, reveals, parallax and pinned galleries that used to need a scroll listener can now be a few lines of CSS.
The feature comes from the CSS Scroll-driven Animations specification. Besides the two timeline functions it adds named timelines (scroll-timeline and view-timeline), animation-range for choosing which part of the scroll drives the animation, and timeline-scope for sharing a named timeline with elements outside the scroller.
The eight demos below each scroll inside their own frame. Every one keeps its content fully visible and usable where the feature is missing, animates only inside @supports (animation-timeline: view()) or scroll(), and switches off when the visitor asks for reduced motion.
scroll() and view(): the two kinds of timeline
A scroll progress timeline runs from 0% when a scroll container is at its start to 100% when it is scrolled to the end. You get one with animation-timeline: scroll(). With no arguments it uses the nearest ancestor scroll container on the block axis, so scroll() and scroll(nearest block) mean the same thing. scroll(root) picks the document scroller and scroll(self) the element itself.
A view progress timeline tracks one element, the subject, as it crosses its scroll container's visible area. animation-timeline: view() starts at 0% when the subject's leading edge first appears and reaches 100% when its trailing edge has gone. view(inline) does the same for horizontal scrolling, and an optional inset narrows the area that counts as visible.
One rule trips almost everyone up: animation-timeline is not part of the animation shorthand, and the shorthand resets it to auto. Always write animation first and animation-timeline after it, as every demo here does.
1. Reading progress bar with scroll()
The classic first demo. A fixed bar scales from scaleX(0) to scaleX(1) on a scroll(root block) timeline, so it is empty at the top of the article and full at the bottom. There is no JavaScript and no scroll event.
Animate transform rather than width. Chrome's documentation for this pattern uses scaleX for exactly that reason: transform can be handled by the compositor, while width changes force layout.
Without support the bar is simply not displayed, because a bar that never moves would be misleading. It is also tied to prefers-reduced-motion: no-preference here; a thin progress bar is mild motion, so keeping it for reduced-motion users is a reasonable choice for your own project.
Show the code
<div class="progress" aria-hidden="true"></div>
<article class="article">
<p class="sda-note sda-note--support" role="note">Your browser doesn't support scroll-driven animations — showing the fallback (no progress bar).</p>
<p class="sda-note sda-note--rm" role="note">Reduced motion is on, so the progress bar is switched off.</p>
<h1>Pour-over coffee at home: a 4-minute method</h1>
<p class="meta">Scroll this page: the bar at the top tracks how far you have read.</p>
<h2>What you need</h2>
<p>A cone dripper with a paper filter, a kettle, a scale and freshly roasted beans. A gooseneck kettle helps you pour slowly, but a steady hand with a normal kettle works too.</p>
<h2>The ratio</h2>
<p>Start at 1:16, which is 30 g of coffee to 480 g of water. If the cup tastes thin, use a little more coffee next time; if it tastes heavy or bitter, use a little less.</p>
<h2>Step by step</h2>
<ol>
<li>Heat the water to just off the boil, around 93 to 96 °C.</li>
<li>Rinse the paper filter with hot water to remove the papery taste and warm the dripper, then discard that water.</li>
<li>Grind 30 g of coffee to the texture of coarse sand and level the bed.</li>
<li>Pour about 60 g of water to wet all the grounds and wait 45 seconds. This is the bloom: trapped gas escapes so the rest of the water can extract evenly.</li>
<li>Pour the remaining water in slow circles, keeping the level steady, until the scale reads 480 g at around the 2:30 mark.</li>
<li>Let it drain. The whole brew should finish between 3:30 and 4:00.</li>
</ol>
<h2>Adjusting the grind</h2>
<p>Grind size is the main control. If the water drains in under three minutes and the coffee tastes sour, grind finer. If it takes longer than four and a half minutes and tastes dry or bitter, grind coarser. Change one thing at a time.</p>
<h2>Storing beans</h2>
<p>Keep beans in an airtight container away from light and heat, and grind just before brewing. Most roasters suggest using them within a month of the roast date.</p>
<p>You reached the end, so the bar is full. <a href="#top">Back to top</a></p>
</article>/* The article reads fine without the bar: it only appears where it can work. */
.progress{
position:fixed;inset:0 0 auto 0;height:5px;z-index:10;
background:linear-gradient(90deg,#b45309,#d97706);
transform-origin:0 50%;
display:none;
}
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.progress{
display:block;
animation:grow-progress linear both;
/* nearest scroller on the block axis: here, the page itself */
animation-timeline:scroll(root block);
}
}
}
@keyframes grow-progress{
from{transform:scaleX(0)}
to{transform:scaleX(1)}
}
.article{max-width:640px;margin:0 auto;padding:36px 22px 60px}
.article h1{font-size:1.75rem;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
.article .meta{font-size:.85rem;color:#52606d;margin-bottom:24px}
.article h2{font-size:1.15rem;margin:28px 0 8px}
.article p,.article li{margin-bottom:12px}
.article ol{padding-left:22px}
.article a{color:#9a3412}2. Reveal on scroll with view() and animation-range
Each step card has two animations on two view() timelines. animation-range picks the slice of the journey each one uses: entry 0% cover 40% for the wipe in, and exit 30% exit 100% for sinking back as the card leaves the top.
The named ranges are cover (the whole journey), contain (while the subject is fully inside, or fully covers, the scroll port), entry and exit (while it crosses the end and start edges), plus entry-crossing and exit-crossing. You can mix them, as in entry 0% cover 40%.
Note the fill modes. The second animation uses forwards, not both. With both, its first keyframe would apply before the exit range starts and override the entry animation, because later animations in the list win for the same property.
The reveal uses clip-path and transform rather than opacity, so the text keeps full contrast at every moment of the animation.
Show the code
<ol class="steps">
<li class="step">
<time>Sat 09:00</time>
<h2>Feed the starter</h2>
<p>Mix 50 g starter with 50 g flour and 50 g water. It is ready when it has doubled and a spoonful floats.</p>
</li>
<li class="step">
<time>Sat 13:00</time>
<h2>Mix and rest</h2>
<p>Combine 500 g bread flour with 350 g water and leave it for 45 minutes so the flour fully hydrates.</p>
</li>
<li class="step">
<time>Sat 13:45</time>
<h2>Add starter and salt</h2>
<p>Squeeze in 100 g active starter and 10 g salt with 15 g extra water until the dough feels uniform.</p>
</li>
<li class="step">
<time>Sat 14:15</time>
<h2>Stretch and fold</h2>
<p>Four sets, 30 minutes apart. Lift one side of the dough, fold it over, turn the bowl and repeat.</p>
</li>
<li class="step">
<time>Sat 18:00</time>
<h2>Shape and chill</h2>
<p>Shape a tight round, place it seam-up in a floured basket and refrigerate overnight.</p>
</li>
<li class="step">
<time>Sun 08:00</time>
<h2>Bake</h2>
<p>Bake in a preheated Dutch oven at 250 °C for 20 minutes with the lid on, then 20 minutes at 230 °C with the lid off.</p>
</li>
<li class="step">
<time>Sun 09:00</time>
<h2>Cool before slicing</h2>
<p>Wait at least an hour. The crumb is still setting, and cutting early makes it gummy.</p>
</li>
</ol>.steps{list-style:none;display:grid;gap:18px;margin-top:28px}
.step{
background:#fff;border:1px solid #e6dccf;border-radius:16px;padding:18px 20px;
box-shadow:0 1px 2px rgba(43,33,24,.06);
}
.step time{display:inline-block;font-weight:700;font-size:.85rem;color:#fff;background:#8a4b16;border-radius:999px;padding:2px 10px;margin-bottom:6px}
.step h2{font-size:1.05rem;margin-bottom:4px}
.step p{color:#4a3b2d;font-size:.95rem}
/* Cards are fully visible by default. Only where view() works,
and only when motion is welcome, do they animate in and out. */
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.step{
animation:
step-in linear both,
step-out linear forwards; /* forwards: does not apply before the exit range */
animation-timeline:view(), view();
animation-range:
entry 0% cover 40%, /* from first pixel in until 40% of cover */
exit 30% exit 100%; /* sink back while leaving the top */
}
}
}
/* A wipe and a lift instead of a fade: the text keeps full contrast
at every point of the animation. */
@keyframes step-in{
from{clip-path:inset(0 0 100% 0 round 16px);transform:translateY(40px)}
to{clip-path:inset(0 0 0 0 round 16px)}
}
@keyframes step-out{
to{transform:scale(.94) translateY(-10px)}
}3. Parallax layers with a named view-timeline
The hero declares view-timeline: --hero block, which gives its visibility a name. The SVG mountain layers inside it use animation-timeline: --hero with animation-range: exit, so they move only while the hero is leaving the top of the screen.
Each layer sinks by a different percentage of its own height, set with a custom property used in a single shared keyframe. The far ridge moves most inside the hero and so appears slowest on screen; the front ridge barely moves and nearly keeps pace with the page.
Named timelines are useful whenever the element that animates is not the element being tracked. Here one tracked element drives four animated ones.
Show the code
<header class="hero">
<p class="sda-note sda-note--support" role="note">Your browser doesn't support scroll-driven animations — showing the fallback (static layers).</p>
<p class="sda-note sda-note--rm" role="note">Reduced motion is on, so the layers stay still.</p>
<div class="hero__text">
<h1>Three huts, two days</h1>
<p>A hut-to-hut walk in the high Alps. Scroll to see the ridges drift at different speeds.</p>
</div>
<svg class="layer layer--far" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<path fill="#a99bd3" d="M0 260 L120 150 L210 210 L330 80 L450 200 L560 120 L690 230 L820 90 L950 190 L1060 130 L1200 220 V400 H0 Z"/>
</svg>
<svg class="layer layer--mid" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<path fill="#7466b0" d="M0 280 L160 170 L280 250 L420 140 L560 260 L700 170 L860 270 L1000 160 L1200 260 V400 H0 Z"/>
</svg>
<svg class="layer layer--front" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<path fill="#3d3170" d="M0 230 Q150 150 300 210 T600 200 T900 190 T1200 210 V400 H0 Z"/>
<path fill="#241a4a" d="M0 320 Q200 260 420 300 T840 290 T1200 300 V400 H0 Z"/>
</svg>
</header>.hero{
position:relative;
min-height:100vh;
overflow:clip;
background:linear-gradient(#fde2c8 0%,#f7c6c9 55%,#d9b4d8 100%);
/* name the hero's visibility so the layers inside can follow it */
view-timeline:--hero block;
}
.layer{position:absolute;inset:auto 0 -1px 0;width:100%;height:78%;display:block}
.layer--far{height:70%}
.layer--mid{height:55%}
.layer--front{height:34%}
.hero__text{position:relative;z-index:2;max-width:560px;padding:56px 24px 0;margin:0 auto;text-align:center;color:#1e1b4b}
.hero__text h1{font-size:clamp(1.7rem,6vw,2.6rem);line-height:1.1;letter-spacing:-.02em;margin-bottom:10px}
.hero__text p{font-size:1rem;color:#2e2a5c}
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.layer,.hero__text{
animation:parallax linear both;
animation-timeline:--hero;
/* exit: from "hero starts leaving the top" to "hero is gone" */
animation-range:exit 0% exit 100%;
}
/* each layer sinks by a different amount, so it moves slower than the page */
.layer--far {--shift:55%}
.layer--mid {--shift:32%}
.layer--front{--shift:10%}
.hero__text {--shift:160%;animation-name:parallax-text}
}
}
@keyframes parallax{
to{transform:translateY(var(--shift))}
}
@keyframes parallax-text{
to{transform:translateY(var(--shift));opacity:0}
}4. Shrinking sticky header
The shrink itself needs no animation at all. The header is position: sticky with top: -36px, so the 36px announcement strip scrolls away and the navigation row stays pinned. That part works in every browser that supports sticky positioning.
The scroll-driven part adds polish over the same distance: animation-range: 0 36px on a scroll(root) timeline. Lengths are allowed in animation-range for scroll timelines, which makes it easy to finish an effect after a fixed number of pixels.
The logo scales with transform and the shadow is a pseudo-element whose opacity fades in, so nothing in the animation changes layout.
Show the code
<header class="site-header">
<p class="site-header__strip">Order by Friday 18:00 for Saturday collection</p>
<div class="site-header__row">
<a class="logo" href="#menu">Harbour Lane Bakery</a>
<nav class="site-nav" aria-label="Main">
<a href="#menu">Menu</a>
<a href="#visit">Visit</a>
<a href="#order">Order</a>
</nav>
</div>
</header>/* The shrink itself is plain CSS: a negative sticky top lets the
36px announcement strip scroll away while the nav row stays pinned. */
.site-header{
position:sticky;top:-36px;z-index:10;
background:#fffdf9;
}
.site-header__strip{
height:36px;display:grid;place-items:center;
font-size:.82rem;background:#7c2d12;color:#fff;
}
.site-header__row{
height:60px;display:flex;align-items:center;justify-content:space-between;gap:12px;
padding:0 20px;position:relative;
}
/* shadow lives on a pseudo-element so we can fade it with opacity */
.site-header__row::after{
content:"";position:absolute;inset:auto 0 0 0;height:12px;transform:translateY(100%);
background:linear-gradient(rgba(36,32,27,.14),transparent);opacity:0;pointer-events:none;
}
.logo{font-weight:800;font-size:1.15rem;letter-spacing:-.01em;color:#24201b;text-decoration:none;transform-origin:0 50%}
.site-nav{display:flex;gap:4px}
.site-nav a{color:#24201b;text-decoration:none;font-size:.92rem;padding:8px 10px;border-radius:8px}
.site-nav a:hover{background:#f1e8dc}
/* Scroll-driven polish, limited to the first 36px of page scroll. */
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.logo,.site-header__row::after{
animation-timing-function:linear;
animation-fill-mode:both;
animation-timeline:scroll(root);
animation-range:0 36px;
}
.logo{animation-name:logo-shrink}
.site-header__row::after{animation-name:shadow-in}
}
}
@keyframes logo-shrink{
from{transform:scale(1.18)}
to{transform:scale(1)}
}
@keyframes shadow-in{
to{opacity:1}
}
@media (max-width:420px){
.site-nav a:nth-child(n+3){display:none}
}5. Horizontal gallery driven by vertical scroll
A tall section (320vh) holds a sticky child that is exactly one viewport high. The section names its own view timeline, and the track's translateX animation uses animation-range: contain, the stretch during which the tall section completely covers the viewport. That is the same stretch during which the sticky child is pinned, so the row stops moving exactly when the section starts to leave.
The end position is translateX(calc(-100% + 100cqw)): minus the track's own width plus the width of its container, which is a size container so cqw is available.
The fallback is the more important half. Without support, or with reduced motion, the section keeps its normal height and the row is a keyboard-focusable horizontal scroller with scroll snapping. The tall-section layout only exists inside the @supports block, so no browser is left with 320vh of empty space.
Show the code
<section class="hscroll" aria-labelledby="hscroll-title">
<div class="hscroll__sticky">
<h2 class="hscroll__title" id="hscroll-title">What is in season</h2>
<div class="hscroll__viewport" tabindex="0" role="region" aria-label="Months, scrollable">
<ul class="hscroll__track">
<li class="month" style="--bg:#f6c9a8"><h3>January</h3><p>Blood oranges, leeks and celeriac.</p></li>
<li class="month" style="--bg:#f3b7c4"><h3>March</h3><p>Forced rhubarb, purple sprouting broccoli.</p></li>
<li class="month" style="--bg:#c9e3b0"><h3>April</h3><p>Wild garlic, spring onions, first radishes.</p></li>
<li class="month" style="--bg:#b9dfc8"><h3>May</h3><p>Asparagus, new potatoes, broad beans.</p></li>
<li class="month" style="--bg:#f5b8ad"><h3>June</h3><p>Strawberries, peas, elderflower.</p></li>
<li class="month" style="--bg:#f7d79a"><h3>August</h3><p>Tomatoes, sweetcorn, courgettes.</p></li>
<li class="month" style="--bg:#d8c3ea"><h3>September</h3><p>Plums, blackberries, early apples.</p></li>
<li class="month" style="--bg:#f2c58c"><h3>October</h3><p>Squash, pumpkins, cobnuts.</p></li>
<li class="month" style="--bg:#c7d6e8"><h3>December</h3><p>Brussels sprouts, parsnips, clementines.</p></li>
</ul>
</div>
</div>
</section>/* Fallback first: an ordinary horizontal scroller with snap points. */
.hscroll__title{font-size:1.15rem;padding:0 20px 12px;width:100%;max-width:680px;margin:0 auto}
.hscroll__viewport{overflow-x:auto;scroll-snap-type:x mandatory;container-type:inline-size}
.hscroll__track{
list-style:none;display:flex;gap:16px;width:max-content;padding:4px 20px 16px;
}
.month{
scroll-snap-align:center;
width:min(240px,72vw);min-height:220px;border-radius:18px;padding:18px;
background:var(--bg);color:#1d2a1f;display:flex;flex-direction:column;justify-content:flex-end;
}
.month h3{font-size:1.4rem;line-height:1.1;margin-bottom:6px}
.month p{font-size:.92rem}
/* Enhancement: the tall section's progress moves the track sideways. */
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.hscroll{
height:320vh; /* scroll distance for the ride */
view-timeline:--gallery block;
}
.hscroll__sticky{
position:sticky;top:0;height:100vh;
display:flex;flex-direction:column;justify-content:center;
}
.hscroll__viewport{overflow:clip}
.hscroll__track{
animation:slide-track linear both;
animation-timeline:--gallery;
/* contain: while the tall section fully covers the viewport,
which is exactly the time the sticky child is pinned */
animation-range:contain 0% contain 100%;
}
}
}
@keyframes slide-track{
/* -100% of the track's own width, plus one viewport width */
to{transform:translateX(calc(-100% + 100cqw))}
}6. Scroll-linked number counter with @property
Custom properties can only be interpolated when they are registered. @property --num with syntax <integer> lets a keyframe animate it from 0 to the element's own value, and counter-reset plus content: counter(num) prints it. The keyframe has only a from step, so the end value is whatever --num is set to on each element.
That also gives a free fallback: without animation support the element still has its real --num and prints the final number. A second registered property, --pct, fills a conic-gradient ring the same way.
In our WebKit testing, counter-reset: num var(--num) printed 0 while the value was mid-animation, because the interpolated value was not a whole number. Wrapping it as calc(var(--num)) rounds it and works in Chromium, Firefox and WebKit.
The visible digits are generated content with empty alternative text (content: counter(num) / ""), and each figure is also present as real, visually hidden text, so screen readers announce 640 rather than a changing number.
Show the code
<ul class="stats">
<li class="stat">
<span class="stat__num" style="--num:640"><span class="sr-only">640</span></span>
<span class="stat__label">volunteers took part</span>
</li>
<li class="stat">
<span class="stat__num" style="--num:27"><span class="sr-only">27</span></span>
<span class="stat__label">native species planted</span>
</li>
<li class="stat">
<div class="ring" style="--pct:93">
<span class="stat__num" style="--num:93;--unit:'%'"><span class="sr-only">93%</span></span>
</div>
<span class="stat__label">of saplings survived the first summer</span>
</li>
</ul>/* Registering the properties makes them animatable as numbers. */
@property --num{syntax:"<integer>";inherits:false;initial-value:0}
@property --pct{syntax:"<number>";inherits:false;initial-value:0}
.stats{list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;max-width:660px;margin:0 auto;padding:0 20px}
.stat{background:#fff;border:1px solid #cfe3dd;border-radius:16px;padding:18px;text-align:center}
.stat__label{display:block;font-size:.9rem;color:#3c5a52}
/* The final value is the real value: --num is set on each element,
so without animation support the correct number is printed. */
.stat__num{
counter-reset:num calc(var(--num)); /* calc() rounds: WebKit interpolates fractions */
font-size:2.4rem;font-weight:800;line-height:1.1;color:#0f5132;
font-variant-numeric:tabular-nums;
}
.stat__num::before{
content:counter(num) var(--unit,"");
content:counter(num) var(--unit,"") / ""; /* hide from screen readers where supported */
}
.ring{
width:96px;aspect-ratio:1;margin:0 auto 8px;border-radius:50%;
background:
radial-gradient(closest-side,#fff 74%,transparent 75%),
conic-gradient(#198754 calc(var(--pct) * 1%),#d6e9e2 0);
display:grid;place-items:center;
}
.ring .stat__num{font-size:1.5rem}
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.stat__num,.ring{
animation:count-up linear both;
animation-timeline:view();
animation-range:entry 40% cover 55%;
}
}
}
/* Only a "from" keyframe: the "to" value is the element's own --num / --pct. */
@keyframes count-up{
from{--num:0;--pct:0}
}7. Stacking cards
The stacking is plain position: sticky, with each card's top offset increased by 10px so the edges of earlier cards stay visible. The scroll-driven part makes each card sink back as the next one slides over it.
The list declares view-timeline: --stack, and every card uses that one timeline with a different slice of it: exit-crossing from i/n to (i+1)/n, where --i is the card's index and --n the number of cards. calc() works inside animation-range, so the slices come from two custom properties.
The last card has no animation because nothing covers it. Without support the cards still stack; they just do not scale.
Show the code
<ol class="stack">
<li class="stack__item" style="--i:0">
<article class="card" style="--bg:#3730a3">
<small>Weeks 1–2</small>
<h2>Discover</h2>
<p>Interviews with your team and five to eight customers, a review of analytics and support tickets, and a one-page summary of what we learned.</p>
</article>
</li>
<li class="stack__item" style="--i:1">
<article class="card" style="--bg:#9d174d">
<small>Weeks 3–4</small>
<h2>Define</h2>
<p>We agree the problem in writing, pick the three journeys that matter most, and set the measures we will judge the work by.</p>
</article>
</li>
<li class="stack__item" style="--i:2">
<article class="card" style="--bg:#115e59">
<small>Weeks 5–9</small>
<h2>Design</h2>
<p>Clickable prototypes tested with real users every Friday, so each round of changes is based on what people actually did.</p>
</article>
</li>
<li class="stack__item" style="--i:3">
<article class="card" style="--bg:#7c2d12">
<small>Weeks 10–12</small>
<h2>Deliver</h2>
<p>Production-ready components, documentation and a handover session with your developers, plus two weeks of support after launch.</p>
</article>
</li>
</ol>.stack{
list-style:none;max-width:620px;margin:0 auto;padding:0 20px;
display:grid;gap:24px;
--n:4; /* number of cards */
view-timeline:--stack block; /* the list's own progress */
}
/* Plain sticky stacking works everywhere. */
.stack__item{position:sticky;top:calc(16px + var(--i) * 10px)}
.card{
min-height:260px;border-radius:20px;padding:24px;color:#fff;
background:var(--bg);box-shadow:0 10px 30px rgba(27,29,42,.18);
transform-origin:50% 0;
}
.card small{font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:.75rem}
.card h2{font-size:1.5rem;line-height:1.15;margin:6px 0 10px}
.card p{max-width:46ch}
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.card{
animation:sink linear both;
animation-timeline:--stack;
/* card i shrinks during its own slice of the list leaving the top:
exit-crossing from i/n to (i+1)/n */
animation-range:
exit-crossing calc(var(--i) / var(--n) * 100%)
exit-crossing calc((var(--i) + 1) / var(--n) * 100%);
}
.stack__item:last-child .card{animation:none}
}
}
@keyframes sink{
to{transform:scale(.9);filter:brightness(.7)}
}8. Scroll-snap carousel with view timelines and timeline-scope
Each slide uses animation-timeline: view(inline) to scale up and become opaque as it reaches the centre of the scroller, and scroll-snap keeps one slide centred. Inline padding of 15% on the scroller lets the first and last slides reach the centre too.
The dots are outside the scroller, so they cannot see the slides' timelines by default: a named timeline is only visible to the element's descendants. timeline-scope on the shared parent fixes that. It declares --s1 to --s5 and --track at the parent, and the slides and scroller attach their timelines to those names. Each dot then follows its slide, and the bar follows the scroller's named scroll-timeline.
The dots are real links to each slide, and the scroller is a focusable, labelled region, so the carousel works by keyboard and without any of the animation.
Show the code
<div class="carousel">
<div class="carousel__scroller" tabindex="0" role="region" aria-label="Neighbourhoods, scrollable">
<article class="slide" id="s1" style="--bg:#fbcfe8"><h2>Alfama</h2><p>The oldest quarter: steep lanes, tiled facades and evening fado.</p></article>
<article class="slide" id="s2" style="--bg:#bae6fd"><h2>Belém</h2><p>Riverside monuments, the tower and custard tarts warm from the oven.</p></article>
<article class="slide" id="s3" style="--bg:#fde68a"><h2>Chiado</h2><p>Old cafés, theatres and bookshops between Baixa and Bairro Alto.</p></article>
<article class="slide" id="s4" style="--bg:#bbf7d0"><h2>Príncipe Real</h2><p>A leafy square, concept stores and a giant cedar you can sit under.</p></article>
<article class="slide" id="s5" style="--bg:#ddd6fe"><h2>Alcântara</h2><p>Converted factories, weekend markets and views of the 25 de Abril bridge.</p></article>
</div>
<nav class="carousel__dots" aria-label="Choose a neighbourhood">
<a href="#s1" aria-label="Alfama"><span></span></a>
<a href="#s2" aria-label="Belém"><span></span></a>
<a href="#s3" aria-label="Chiado"><span></span></a>
<a href="#s4" aria-label="Príncipe Real"><span></span></a>
<a href="#s5" aria-label="Alcântara"><span></span></a>
</nav>
<div class="carousel__bar" aria-hidden="true"></div>
</div>.carousel{
max-width:720px;margin:0 auto;
/* hoist the named timelines so siblings of the scroller can use them */
timeline-scope:--track,--s1,--s2,--s3,--s4,--s5;
}
.carousel__scroller{
display:flex;gap:14px;overflow-x:auto;
scroll-snap-type:x mandatory;
padding:6px 15% 14px; /* lets the first and last slide reach the centre */
scroll-timeline:--track inline;
scrollbar-width:thin;
}
.slide{
flex:0 0 70%;scroll-snap-align:center;
min-height:210px;border-radius:18px;padding:20px;
background:var(--bg);color:#0f172a;
display:flex;flex-direction:column;justify-content:flex-end;
}
.slide h2{font-size:1.35rem;margin-bottom:4px}
.slide p{font-size:.92rem}
.slide:nth-child(1){view-timeline:--s1 inline}
.slide:nth-child(2){view-timeline:--s2 inline}
.slide:nth-child(3){view-timeline:--s3 inline}
.slide:nth-child(4){view-timeline:--s4 inline}
.slide:nth-child(5){view-timeline:--s5 inline}
.carousel__dots{display:flex;justify-content:center;gap:2px;margin-top:8px}
.carousel__dots a{display:grid;place-items:center;width:28px;height:28px;border-radius:50%}
.carousel__dots span{width:10px;height:10px;border-radius:50%;background:#94a3b8}
.carousel__bar{display:none;height:3px;margin:10px auto 0;width:min(240px,60%);background:#334155;border-radius:3px;overflow:hidden}
.carousel__bar::before{content:"";display:block;height:100%;background:#fbbf24;transform-origin:0 50%;transform:scaleX(0)}
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){
.carousel__scroller{scroll-behavior:smooth}
/* each slide follows its own position in the scroller */
.slide{
animation:focus-slide linear both;
animation-timeline:view(inline);
}
/* each dot follows a slide it is not inside of: timeline-scope makes that legal */
.carousel__dots span{animation:dot-active linear both}
.carousel__dots a:nth-child(1) span{animation-timeline:--s1}
.carousel__dots a:nth-child(2) span{animation-timeline:--s2}
.carousel__dots a:nth-child(3) span{animation-timeline:--s3}
.carousel__dots a:nth-child(4) span{animation-timeline:--s4}
.carousel__dots a:nth-child(5) span{animation-timeline:--s5}
/* the bar follows the scroller's named scroll timeline */
.carousel__bar{display:block}
.carousel__bar::before{animation:fill-bar linear both;animation-timeline:--track}
}
}
@keyframes focus-slide{
0%,100%{transform:scale(.86);opacity:.55}
50%{transform:scale(1);opacity:1}
}
@keyframes dot-active{
0%,30%,70%,100%{transform:scale(1);background:#94a3b8}
50%{transform:scale(1.5);background:#fbbf24}
}
@keyframes fill-bar{
to{transform:scaleX(1)}
}Fallbacks and progressive enhancement
Write the static version first and treat the animation as an extra. Every demo here sets the finished state as the normal style: cards visible, numbers final, gallery scrollable. The scroll-linked rules live inside @supports (animation-timeline: view()) or @supports (animation-timeline: scroll()), so browsers without the feature never see them.
Be careful with content that starts hidden. If the normal style sets opacity: 0 and relies on the animation to reveal it, browsers without the feature leave it invisible. An animation declared outside @supports does not disappear in those browsers either: it runs as an ordinary time-based animation, playing once on load if it has a duration, or jumping straight to its fill state with the default duration. Keeping the whole animation declaration inside the @supports block avoids both problems.
A short note inside each demo appears only when @supports fails. We checked every demo in Firefox with layout.css.scroll-driven-animations.enabled turned off, and in Chromium with the @supports blocks disabled: the content stays complete and usable, and the note shows.
Performance and accessibility
Chrome's case study on scroll-driven animation performance compares a scroll-listener progress bar with the CSS version under a heavy JavaScript workload: the CSS version was unaffected, and the API is designed to use as little main-thread work as possible. Stick to transform and opacity where you can; Chrome's guide uses transform instead of width specifically so the animation can be composited.
Scroll-linked motion can make people ill. WCAG's understanding document for Success Criterion 2.3.3, Animation from Interactions (Level AAA), lists parallax scrolling as an example and notes that vestibular disorder reactions include dizziness, nausea and headaches. Every demo wraps its animations in @media (prefers-reduced-motion: no-preference), so visitors who ask for less motion get the static version.
Do not hide content behind an animation that may never run, keep text contrast intact during reveals, and make sure anything the effect replaces, such as a horizontal scroller, is still reachable by keyboard.
Frequently asked questions
What is the difference between scroll() and view() in CSS?
scroll() creates a timeline from a scroll container's scroll position: 0% at the start, 100% at the end. view() creates a timeline from one element's visibility inside its scroll container, from the moment it starts to enter until it has completely left. Use scroll() for page-level effects like progress bars and view() for effects on individual elements, like reveals.
Which browsers support CSS scroll-driven animations?
Chrome and Edge support them from version 115, with timeline-scope from 116. Safari supports them from Safari 26. Firefox enables them by default from Firefox 159, which ships on 27 October 2026; earlier versions have them behind the layout.css.scroll-driven-animations.enabled preference.
Why is my animation-timeline not doing anything?
The most common cause is declaring animation-timeline before the animation shorthand, which resets it to auto. Also check that the scroller can actually scroll, and that a named timeline is defined on an ancestor of the animated element, or hoisted with timeline-scope when it is not.
Can I control scroll-driven animations from JavaScript?
Yes. The same specification defines the ScrollTimeline and ViewTimeline interfaces, and Element.animate() accepts a timeline option plus rangeStart and rangeEnd. ScrollTimeline and ViewTimeline are listed in the same versions as the CSS properties: Chrome 115, Safari 26 and Firefox 159.
How do I make scroll-driven animations accessible?
Put the scroll-linked rules inside @media (prefers-reduced-motion: no-preference) so they switch off for people who ask for less motion, and make the static version complete on its own. Avoid reveals that drop text contrast, and never leave content hidden at the start of an animation that might not run.
More from the Modern CSS lab
- CSS :has() Selector: 8 Practical Examples of the Parent Selector
- CSS Container Queries: 8 Practical @container Examples
- CSS Anchor Positioning: 8 Practical Examples
- HTML Popover API: 8 Practical Examples
- CSS View Transitions: 8 Live Examples of the View Transition API
- Animating height: auto and display: none in CSS
- CSS Subgrid: Align Cards, Forms and Layouts Across Nested Grids