Horizontal Scroll Gallery with a Scroll Timeline
A horizontal gallery is the clearest demonstration of what scroll-driven animation replaced. The old version needed a scroll listener, per-item geometry reads, and index arithmetic to work out which card was centred. The CSS version needs one rule for the items, one for the indicator, and no script at all — because each item can read its own visibility while the container reads its own offset. This guide builds the whole component. It sits under scroll-driven media and gallery effects.
When to use this approach
- A row of cards, images or products that should scroll sideways — the pattern’s home ground, and the case where per-item progress removes the most code.
- When you want a focus effect — items growing as they reach the centre, dimming as they leave — without maintaining an “active index” in state.
- When the row needs an indicator — the container’s own progress is meaningful in a way a vertical grid’s is not, which is what makes an indicator worth adding here.
Reach for something else when the content is a linear sequence the reader must not skip, when each item needs its own controls, or when the row is short enough to fit — a gallery that never scrolls is a row, and animating it is noise.
Implementation
1. Build the scroller
.gallery {
display: flex;
flex-wrap: nowrap; /* wrapping removes the horizontal range entirely */
gap: 1rem;
overflow-x: auto;
scroll-timeline: --gallery inline;
overscroll-behavior-inline: contain;
}
.gallery > .item {
flex: 0 0 clamp(220px, 30vw, 320px);
}
flex-wrap: nowrap is load-bearing. With wrapping, the row becomes a grid, nothing overflows horizontally, and every timeline attached to the container resolves to an empty range.
2. Give each item its own progress
@supports (animation-timeline: view()) {
.gallery > .item {
animation: settle linear both;
animation-timeline: view(inline);
animation-range: cover 15% cover 85%;
}
@keyframes settle {
from, to { scale: 0.88; opacity: 0.55; }
50% { scale: 1; opacity: 1; }
}
}
The symmetric keyframe set is the trick: from and to are the edge state and the midpoint is the focused state, so an item grows as it arrives and shrinks as it leaves without any per-item selector.
3. Drive the indicator from the container
.gallery-shell { timeline-scope: --gallery; }
.gallery-progress {
block-size: 4px;
transform-origin: left;
animation: fill linear both;
animation-timeline: --gallery;
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
The indicator is a sibling of the gallery rather than a child, so the anonymous form cannot reach the scroller and the name is required — the situation described in anonymous versus named timelines.
4. Fade the edges from the container’s own offset
.gallery {
--fade-start: 1;
--fade-end: 0;
mask-image: linear-gradient(
to right,
transparent 0,
black calc(var(--fade-start) * 3rem),
black calc(100% - var(--fade-end) * 3rem),
transparent 100%
);
animation: edges linear both;
animation-timeline: scroll(self inline);
}
5. Make it usable without a pointer
<div class="gallery" tabindex="0" role="group" aria-label="Featured projects">
...
</div>
Verification
Check the horizontal range exists: gallery.scrollWidth > gallery.clientWidth. If they are equal the row fits, and every timeline bound to it is inert regardless of what the stylesheet says.
Then confirm the axis took. Scroll the gallery sideways and watch the items; if they respond to vertical page scrolling instead, the axis keyword is missing or a shorthand reset it to block.
Tab into the component and confirm the container takes focus and responds to arrow keys, then tab through the items and confirm each scrolls into view as it receives focus. Both behaviours are independent of the animation and both are commonly missing.
Finally, test at a narrow viewport where the items may become full-width. A one-item-per-screen gallery has very different range behaviour, and a cover 15% to cover 85% range that looks right on desktop can leave items permanently mid-animation on a phone.
Edge cases and gotchas
Mandatory scroll-snap makes the indicator step. The scroller moves in discrete jumps, so anything reading its progress jumps too. scroll-snap-type: inline proximity keeps the snapping benefit while allowing continuous positions between snap points.
Focus scrolling fights the animation. When an item receives focus the browser scrolls it into view, which drives the timeline — this is correct and occasionally jarring at speed. scroll-behavior: smooth on the container makes the resulting movement legible instead of instantaneous.
Momentum overshoot on touch. A flick can carry the scroller past several items, so an effect tuned to a slow drag reads as a blur. Keep the per-item movement small; the effect should survive being scrolled quickly.
gap and the last item. With gap and no trailing padding, the final item sits flush against the container edge and its view() progress never reaches the same values as the others. scroll-padding-inline and matching container padding keep the geometry symmetric.
Nested inside a vertical scroller. The gallery is a nested scroll container, so anything inside it resolving scroll(nearest) finds the gallery rather than the page — see nested scroll containers.
Browser-specific notes
All three engines drive horizontal timelines from the compositor, so the animation quality is high everywhere, including on touch devices where the whole gesture is compositor-driven.
Chromium and WebKit differ in horizontal momentum decay, so the same flick produces different progress curves. This is a feel difference rather than a correctness one, and it is one more argument for linear easing on scroll-driven effects — the reader supplies the acceleration.
Safari applies rubber-band overscroll on the inline axis. Progress clamps at the endpoints, so items hold their edge state during the spring-back rather than overshooting, which is the correct behaviour and can read as a brief pause.
Firefox generates larger discrete deltas for wheel-plus-modifier horizontal scrolling than trackpads do, so the same gallery can feel continuous on a laptop and stepped on a desktop. Testing with both input methods is worth the couple of minutes it takes.
Frequently Asked Questions
Why use view(inline) for items rather than one scroll(inline) timeline?
Because a scroll timeline gives every item the same value, so producing per-item behaviour from it requires computing a distinct range for each item from its index and width — arithmetic that breaks the moment the item count, the gap or the item width changes. A view timeline gives each item its own progress by construction, which is why the rule can be written once and left alone.
Can the gallery be a <ul>?
Yes, and it usually should be. The scroll container can be the list itself with display: flex and list-style: none, which keeps the semantics of a list of items while getting the layout you want. The tabindex and accessible name go on the list element in that case.
Does this work with CSS scroll-snap?
Yes, with the caveat about mandatory snapping above. Snap points and scroll timelines read the same scroll offset and do not conflict; what changes is the shape of the progress curve, which becomes stepped under mandatory and stays continuous under proximity. See scroll-snap and scroll timelines together for the detail.
How many items is too many?
The animation cost is negligible — items outside the scrollport sit clamped at an endpoint and do nothing. The cost that matters is layer memory if each item is a large image, and DOM size if the list is very long. A few dozen items is unremarkable; several hundred image-heavy ones wants virtualisation for reasons that have nothing to do with the timeline.
Should the gallery hide its scrollbar?
Only if there is another affordance in its place. A hidden scrollbar on a horizontally scrolling region removes the main visual signal that the region scrolls at all, and the edge fade this guide adds is a weaker signal than most designers assume — it reads as a style choice rather than as an instruction.
The indicator helps, because a partially-filled bar implies there is more. Where the scrollbar is hidden entirely, pairing it with visible next and previous controls is the only version that reliably communicates the affordance to a first-time visitor.
Related
- Horizontal scroll timelines and the inline axis — the axis rules this pattern depends on
- Scroll-snap and scroll timelines together — what snapping does to a continuous indicator
- Building Scroll Progress Indicators — the indicator pattern in its vertical form
- Anonymous vs named scroll timelines — why the indicator needs a name