Horizontal Scroll Timelines and the Inline Axis
Every timeline function defaults to the block axis, which on a normal page means vertical. Attach one to a horizontally scrolling gallery without changing that default and it reads a range of zero: the animation attaches successfully, reports a resolved timeline, and never advances. This guide covers axis selection, the difference between the logical and physical keywords, the gallery patterns that need them, and what changes for right-to-left readers. It sits under timeline attachment and scroll container mechanics.
When to use this approach
- A horizontally scrolling gallery or carousel — an indicator, a per-item reveal, or a background that shifts as the row scrolls all need the inline axis named explicitly.
- A scrollable code block, table or timeline strip —
scroll(self inline)drives edge affordances that tell the reader there is more to the side. - A page in a vertical writing mode — the logical keywords swap meaning, and using them is what makes the effect follow the writing mode rather than fight it.
- A right-to-left interface — direction changes which end is zero progress, and animating logical properties is what keeps the effect coherent.
Implementation
1. Name the axis on the timeline
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-timeline: --gallery inline;
}
The shorthand takes the name and the axis together. Written as longhands it is scroll-timeline-name: --gallery; scroll-timeline-axis: inline; — and forgetting the second one is the most common version of this bug, because the name resolves and the axis silently stays block.
2. Drive an indicator from the scroller
.gallery-shell { timeline-scope: --gallery; }
.gallery-progress {
transform-origin: left;
animation: fill linear both;
animation-timeline: --gallery;
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
3. Give each item its own progress
.gallery .item {
animation: rise linear both;
animation-timeline: view(inline);
animation-range: entry 10% cover 40%;
}
@keyframes rise {
from { scale: 0.9; opacity: 0.4; }
to { scale: 1; opacity: 1; }
}
view(inline) measures each item’s progress across the gallery’s own scrollport on the horizontal axis, so items scale up as they arrive and back down as they leave — with no per-item selectors and no index arithmetic.
4. Add edge affordances with self
.code-scroller {
overflow-x: auto;
animation: fade-end-mask linear both;
animation-timeline: scroll(self inline);
}
@keyframes fade-end-mask {
from { --mask-end: 1; }
to { --mask-end: 0; }
}
The element reads its own scroll offset, so the trailing fade disappears exactly as the reader reaches the end of the content.
5. Keep the effect direction-aware
/* follows the reader's direction */
@keyframes slide-in {
from { translate: calc(-1 * var(--gap)) 0; }
to { translate: 0 0; }
}
.item { --gap: 24px; }
[dir="rtl"] .item { --gap: -24px; }
Logical properties handle most cases automatically; translate and transform are physical, so a direction-aware custom property is the simplest way to keep them coherent.
Verification
Confirm the scroller has an inline-axis range: el.scrollWidth > el.clientWidth. A flex row whose items wrap has no horizontal overflow, and flex-wrap: nowrap is frequently the missing piece rather than anything to do with timelines.
Then check the axis actually took effect. Scroll the gallery horizontally and watch the animation; if nothing moves but vertical page scrolling does move it, the timeline is still reading the block axis — either the axis keyword is missing or a shorthand reset it.
Finally, test with dir="rtl" on the document. Progress should still run from the reader’s start edge to their end edge, and any physically-expressed movement should be checked for coherence. This takes one attribute change and catches a class of bug that is otherwise reported by users rather than by testing.
Edge cases and gotchas
A shorthand that resets the axis. scroll-timeline: --gallery with no axis sets the axis to its initial value, block. Because the name resolves, everything downstream looks correct — the animation attaches, the computed value is populated, and it never advances.
Flex items that wrap. Without flex-wrap: nowrap the row wraps to multiple lines instead of overflowing, which removes the horizontal scroll range entirely. The container still says overflow-x: auto, and nothing scrolls.
Scroll-snap interfering with measured progress. A gallery with mandatory snapping moves in discrete jumps, so a progress indicator bound to it steps rather than glides. Proximity snapping preserves continuous progress between snap points, which usually reads better with a scroll-driven indicator.
Trackpad and shift-scroll behaviour. Horizontal scrolling is frequently performed with a modifier key or a two-finger gesture, and momentum characteristics differ from vertical scrolling on every platform. An effect tuned to feel right under a slow drag can feel abrupt under a flick.
Vertical writing modes swap the logical axes. inline becomes vertical in vertical-rl, which is correct behaviour and catches people who used inline to mean “horizontal”. If the effect genuinely depends on the physical direction, x is the honest keyword.
Browser-specific notes
All three engines implement the axis keywords as specified, including the writing-mode-relative resolution of block and inline. Differences are concentrated in scrolling behaviour rather than in timeline semantics.
Chromium and WebKit differ in how horizontal momentum decays, so the same flick produces a different progress curve. This is a feel difference rather than a correctness one, but it is enough that an easing tuned to feel right in one engine can feel abrupt in the other — one more reason linear is usually the better easing for scroll-driven effects.
Safari applies rubber-band overscroll on the inline axis as well as the block axis, and progress is clamped to the valid range while the container springs back. Effects that read as “held at the end” during that spring are behaving correctly.
Firefox’s horizontal scrolling with a mouse wheel and a modifier key generates larger discrete deltas than trackpad scrolling, so a gallery that feels continuous on a laptop can feel stepped on a desktop with a wheel mouse. Testing with both input devices is worth the two minutes.
Frequently Asked Questions
Should I use inline or x for a gallery?
inline, unless the effect genuinely depends on the physical direction. inline follows the writing mode, so the same stylesheet behaves correctly in a vertical writing mode without a second rule. x is the right choice only when the animation is tied to physical geometry — a compass, a map, a ruler — where the writing mode should not change the meaning.
Can one gallery drive both a horizontal and a vertical effect?
Yes, with two timelines. A named scroll timeline on the inline axis drives effects tied to the gallery’s own scrolling, while a view() timeline on the block axis can drive an effect tied to the gallery’s position in the page. Two animations on the same element can read them separately as long as they animate different properties.
Why does my indicator jump between positions?
Almost always scroll-snap with mandatory behaviour. The scroller moves in discrete steps, so progress does too. Switching to proximity keeps the snapping benefit while allowing continuous positions between snap points, which is what a scroll-driven indicator needs to look smooth.
Does a horizontal timeline work on a touch device?
Yes, and it is one of the cases where scroll-driven animation is a clear improvement over the alternatives — touch scrolling on a horizontal container is driven entirely by the compositor, so an animation bound to it stays smooth in a way that a scroll-event-driven equivalent does not. The thing to test is the momentum tail, which is longer on touch than on a trackpad and makes fast-moving effects overshoot perceptually.
Is a horizontal gallery accessible to keyboard users?
Only if you make it so. A scrollable container is focusable and scrollable with the arrow keys once it has tabindex="0", and without that a keyboard user has no way to reach content that is scrolled out of view. The animations themselves are irrelevant to this — the container would need it with or without them — but a gallery built specifically to show off a scroll-driven effect is exactly the kind of component where it gets forgotten.
Give the container an accessible name as well, so a screen-reader user landing on it understands what they are about to scroll through rather than hearing an unlabelled scrollable region.
Related
- Which element becomes the scroller — resolving the container before choosing an axis
- Scroll timelines on nested scroll containers — a horizontal gallery inside a vertical page is a nested case
- Understanding the CSS Scroll-Timeline API — the timeline functions and their arguments
- Building Scroll Progress Indicators — the indicator pattern, applied vertically