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.

One scroll offset, three pieces of feedbackNo script sits between the scroll position and the indicators.One scroll offset, three pieces of feedbackTrack scrollssnap, momentum--carouseltimelinescroll(inline)Progress barscaleSlide view()per slideCentred slideemphasisscale, opacity
No script sits between the scroll position and the indicators.

Step-by-step resolution

Wiring indicators to the track's scroll positionEach step replaces one piece of what a scroll listener used to compute.Wiring indicators to the track's scroll position1Snap slides with scroll-snap-type: x mandatory and scroll-snap-align: center.The browser owns snapping2Declare scroll-timeline: --carousel inline on the track.A named 0-to-1 timeline3Add timeline-scope: --carousel to the carousel's outer element.The bar outside the track can see it4Animate the bar's scale from 0 to 1 on that timeline.Continuous progress5Give each slide animation-timeline: view(inline) and a keyframe peaking at 50%.The centred slide is largest6Keep dots as real links to slide ids for keyboard and assistive tech.
Each step replaces one piece of what a scroll listener used to compute.

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. scale and opacity driven 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.

Main-thread scripting during a two-second swipeMid-range phone. Scroll-timeline indicators do no script work while the track moves.Main-thread scripting during a two-second swipescroll listener computing index and progress412 msscroll listener throttled with rAF138 msIntersectionObserver per slide (coarse)21 msScroll-driven animations0 ms
Mid-range phone. Scroll-timeline indicators do no script work while the track moves.

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-scope must 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 opacity on 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.