Scroll-Snap Carousel Indicators with Scroll Timelines
Part of Scroll Timeline Scoping & Ranges in Modern View Transitions & Scroll APIs.
The problem
A horizontal carousel built on CSS scroll snapping is already accessible, touch-friendly and fast: the browser handles momentum, snapping and keyboard scrolling. What it lacks is feedback. Designs ask for a progress bar under the track, the centred slide slightly larger than its neighbours, and dots showing which slide is active.
The usual implementation adds a scroll listener that reads scrollLeft, computes an index and writes styles — work on the main thread for every scroll event, which lags behind compositor-driven scrolling and stutters exactly while the user is swiping. An IntersectionObserver version is cheaper but coarse: it reports thresholds, not continuous progress, so the progress bar jumps.
Root cause analysis: the feedback is a function of scroll position
Everything the design asks for is a pure function of the track’s horizontal scroll offset. A function of scroll position is what scroll-driven animations express, and when they animate compositor-friendly properties they are updated alongside scrolling itself rather than in response to events.
Two timeline types cover the three pieces of feedback. A scroll progress timeline on the track’s inline axis goes from 0 at the first slide to 1 at the last, which is exactly a progress bar. A view progress timeline on each slide, measured on the inline axis, tracks the slide’s journey across the track’s visible area, which is exactly what “how centred is this slide” means.
The complication is scope. The progress bar usually sits outside the track, as a sibling, and a named timeline is only visible to the scroller’s descendants by default. timeline-scope on a shared ancestor lifts the name so siblings can attach to it — the mechanism covered in the parent topic on scroll timeline scoping.
Step-by-step resolution
Production code pattern
<section class="carousel" aria-roledescription="carousel" aria-label="Featured work">
<div class="carousel__track" tabindex="0">
<article class="carousel__slide" id="slide-1"> … </article>
<article class="carousel__slide" id="slide-2"> … </article>
<article class="carousel__slide" id="slide-3"> … </article>
</div>
<div class="carousel__progress" aria-hidden="true"><span></span></div>
<nav class="carousel__dots" aria-label="Slides">
<a href="#slide-1">1</a><a href="#slide-2">2</a><a href="#slide-3">3</a>
</nav>
</section>
.carousel {
timeline-scope: --carousel; /* lift the name above the track */
}
.carousel__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 80%;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-timeline: --carousel inline; /* 0 at the first slide, 1 at the last */
}
.carousel__slide {
scroll-snap-align: center;
animation: emphasise linear both;
animation-timeline: view(inline); /* each slide's own trip across the track */
}
.carousel__progress span {
display: block;
block-size: 3px;
background: currentColor;
transform-origin: left;
animation: fill linear both;
animation-timeline: --carousel; /* a sibling of the track, reached via timeline-scope */
}
@keyframes emphasise {
0%, 100% { scale: 0.92; opacity: 0.6; }
50% { scale: 1; opacity: 1; } /* centred in the track */
}
@keyframes fill {
from { scale: 0 1; }
to { scale: 1 1; }
}
@media (prefers-reduced-motion: reduce) {
.carousel__track { scroll-behavior: auto; }
.carousel__slide { animation: none; } /* no scaling as slides pass */
/* The progress bar stays: it moves only as far as the user scrolls. */
}
Rendering Impact: composite.
scaleandopacitydriven by scroll timelines are updated with the scroll on the compositor where the engine supports it; no script runs during the swipe.
The reduced-motion branch keeps the progress bar deliberately. Its movement is directly and proportionally caused by the user’s own scroll, which is the kind of motion reduced-motion guidance treats as acceptable; the slide scaling is extra motion layered on top and is removed.
Dots and the active state
A progress bar can be continuous; a dot is either current or not, which is discrete and harder to express as an animation. Three approaches exist, in decreasing order of support.
The dependable one is a small IntersectionObserver on the slides, with a threshold near 0.6, that toggles aria-current on the matching dot. It runs only when a slide crosses the threshold, not per scroll event, and keeps the active state in the accessibility tree where it belongs.
Newer engines can do it without script. Scroll-state container queries let a slide style itself when it is the snapped target, and experimental carousel primitives generate scroll markers with a current-state pseudo-class. Both are promising and both are, at the time of writing, limited to some engines; treat them as enhancements over the observer, not replacements.
Whatever the mechanism, keep the dots as real links or buttons that scroll the track, so keyboard and screen reader users can navigate without swiping.
Verification checklist
Constraints and trade-offs
- Where scroll-driven animations are unsupported, the indicators stay at their un-animated state; give the bar and slides sensible static styles.
view(inline)measures against the nearest scroll container on the inline axis; nested scrollers inside slides can capture it.timeline-scopemust be on an ancestor of both the track and the indicator, and the name must be unique within that ancestor.- Scaling slides changes their visual size but not their layout, so snap points remain based on the unscaled boxes, which is usually what you want.
- Animating
opacityon large slides with images keeps them composited while visible; limit the number of fully rendered slides in long carousels.
Frequently asked questions
Why use a scroll timeline instead of a scroll event listener?
A listener runs on the main thread after the scroll happens, so its updates lag and can stutter during fast swipes. A scroll-driven animation is evaluated with the scroll itself, needs no script and keeps working while the main thread is busy.
Why does my progress bar outside the track not animate?
Named scroll timelines are visible only to descendants of the scroller by default. Declare timeline-scope with the same name on an element that contains both the track and the bar.
Can the active dot be done in pure CSS?
In some engines, with scroll-state container queries or newer carousel scroll-marker features. For broad support, use a lightweight IntersectionObserver to set aria-current, which also exposes the active state to assistive technology.
Is slide scaling a problem for reduced-motion users?
It adds motion beyond what the user’s scroll directly causes, so remove it under prefers-reduced-motion. The progress bar, which moves only as far as the user scrolls, can stay.
Related
- Scroll Timeline Scoping & Ranges — timeline-scope and ranges explained
- Named vs Anonymous Scroll Timelines — when a timeline needs a name at all
- Building a Scroll Progress Bar — the vertical page-level version of the same indicator