Horizontal Scroll Section
Six 200 px panels on a track pinned inside a 280 px area that scrolls over 840 px: a named timeline (scroll-timeline: --hscrollTimeline) translates the track by −75% of its own width — −984 px for 1,312 px, measured linear — with no hard-coded pixel and no JavaScript; Firefox, which does not animate on scroll, gets the track laid flat through an @supports fallback.
Updated
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
Four nested boxes and one animation. The area .hscroll-outer (100% × 280px, overflow: hidden auto) is the scrolling container and declares the named timeline: scroll-timeline: --hscrollTimeline y. Inside it, .hscroll-spacer is 400% of the area, 1,120 px: the travel is 1,120 − 280 = 840 px. The block .hscroll-sticky (position: sticky; top: 0, 280 px, overflow: hidden) stays pinned at the top of the area for the whole travel (measured: top at 0 from start to end), and the track .hscroll-track — flex, gap: 16px, padding: 20px 16px, width: max-content, 1,312 px for six 200 px panels — carries the scrollHorizontal animation (translateX(0) → translateX(-75%)) with animation-timeline: --hscrollTimeline, animation-duration: auto, linear and animation-fill-mode: both. Measured in Chromium in 210 px steps: −246, −492, −738, −984 px at 840, i.e. 1.17 px of track per scrolled pixel, linear; wheel back up and the track returns to 0. The named timeline resolves to the nearest ancestor that declares it: two copies pasted in the same page are independent (measured: wheel over the second one, −492 px for it, 0 for the first). Empty JavaScript field, no console error, no site variable.
The -75% is the heart of the effect: a translateX percentage refers to the width of the transformed element, hence to the track. Add a panel and the track grows to 1,528 px, the translation to −1,146 px, with no CSS change (measured: a seventh panel fully visible from 166 to 366 px at the end of the travel). That is what a pixel value cannot do — Horizontal Scroll (fx-0564) fixes -600px. The flip side: what stays visible at the end is always the last quarter of the track, 328 px, whatever the frame width — at 1,264 px wide, 952 px stay empty on the right (see the FAQ). The three scenes on this page therefore give the track four times the frame width: 341 px panels for 540 px, and the last panel stops at the edge.
The sold code was fixed on two points. The area was height: 100%: in a parent without a fixed height (.demo-preview only has a min-height), that percentage falls back to auto, the area takes the height of its content, the travel is 0 and the track never moves — measured pasted into a blank page at 1,280 and 390 px, and in the site's own preview. It is now 280px, the height of the pinned block. And a @supports not (animation-timeline: scroll()) block was added for browsers without scroll-driven animations, Firefox first (see compatibility). One foreign element remains: the hint .slr-scroll-hint “Scrollez verticalement” (prefix from another effect, hard-coded French, 11.2 px, white at 40%, a 4 px bounce every 2 s), in position: absolute 8 px from the bottom of .demo-preview — outside that frame, it drops to the bottom of the page.
Accessibility
- prefers-reduced-motion not in the code: measured under emulation, nothing changes — −984 px at the end of the travel, hint still bouncing (
animation-play-state: running). The horizontal motion only happens at the reader's request, but WCAG 2.3.3 also covers interaction-triggered animation: add@media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }and, to remove the axis conversion, repeat inside that media query the three rules of the@supports notblock (spacer toauto, sticky tostatic+overflow-x: auto,animation: none): the track then reads flat, with native horizontal scrolling. - Keyboard: measured in Chromium, Tab focuses the area without a
tabindex(outline: auto 1pxring); Arrow Down moves 40 px (track at −46.9 px), Page Down 285 px (−334 px), End goes all the way (−984 px), Home returns to 0. Firefox and Safari do not focus a scrolling area withouttabindex="0": add it on.hscroll-outer, with anaria-labeland a:focus-visiblestyle. With a finger, the gesture must start inside the 280 px of the area;overscroll-behavior: containkeeps the page from being dragged at the ends. - Contrast (white 19.2 px text at weight 700: large text, 3:1 threshold): panels 1 (4.5:1 and 4.2:1) and 6 (5.7:1 and 4.7:1) pass, panel 2 barely (3.5:1 and 3.7:1); panels 3, 4 and 5 fail on their first color — 2.4:1 on
#06b6d4, 2.2:1 on#f59e0b, 2.5:1 on#10b981. Darken those start colors or put a drop shadow on the text. The hint at 40% white reads 3.8:1 for 11.2 px: below 4.5:1. - Screen readers and fallback: six
divs with no heading, read in DOM order whatever the track position — nothing is hidden from speech, but nothing gives structure either: give each panel a heading (h3orh4) and the area anaria-label. The hint “Scrollez verticalement” is read in French whatever the page language:aria-hidden="true". In the fallback withoutanimation-timeline, the.hscroll-stickyblock is what scrolls horizontally: it needs the sametabindex="0"for the keyboard.
Browser compatibility
CSS only: named scroll-timeline, animation-timeline, animation-duration: auto, position: sticky, two-value overflow: hidden auto, width: max-content, flexbox, gradients. Zero JavaScript, zero dependencies. Scroll-driven animations exist in Chrome and Edge 115+, Safari and iOS 26 (measured in Playwright's WebKit: −984 px at the end of the travel); Firefox runs them in no version: measured in Firefox 153, CSS.supports('animation-timeline: scroll()') returns false and, without a fallback, the animation jumps to its final state — track frozen at −984 px, panels 1 to 4 out of frame, only panel 6 and half of 5 visible. The @supports not (animation-timeline: scroll()) block of the sold code then takes over: measured, the six panels in place (16 to 1,296 px), an area with no vertical travel, the .hscroll-sticky block in position: static and overflow-x: auto — 48 px of horizontal travel at 1,264 px wide, 938 px at 374 px, horizontal wheel and Shift+wheel work. A Firefox visitor reads a row of panels that slides with a finger or the wheel, with no pinned track.
Before Chrome/Edge 115 and Safari 26, the same @supports not block applies: track laid flat, as in Firefox (@supports has existed since Chrome 28, Firefox 22 and Safari 9). Without the two-value form overflow: hidden auto (Chrome < 68, Firefox < 61, Safari < 12.1), the area no longer scrolls vertically — but those versions have no animation-timeline either, the fallback applies and nothing is lost. Without position: sticky (before 2017), the block rises with the stage and leaves the area after 280 px.
The code
HTML structure to paste into your page (the CSS + JS come with Premium):
<div class="demo-preview">
<div class="hscroll-outer">
<div class="hscroll-spacer">
<div class="hscroll-sticky">
<div class="hscroll-track">
<div class="hscroll-panel">…</div>
<div class="hscroll-panel">…</div>
<div class="hscroll-panel">…</div>
<div class="hscroll-panel">…</div>
<div class="hscroll-panel">…</div>
<div class="hscroll-panel">…</div>
</div>
</div>
</div>
</div>
<span class="slr-scroll-hint">…</span>
</div>
The full HTML + CSS + JS of Horizontal Scroll Section, ready to paste — plus all 811 effects. Or €299 once, lifetime access.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Travel (CSS — .hscroll-spacer { height: 400% }, .hscroll-outer { height: 280px }) |
1,120 px stage, 840 px travel (1.17 px of track per pixel) | Travel = stage − area, three times the area height at 400%. 600% gives 1,400 px of travel and a track twice as slow under the wheel. The pinned block must keep the area's height: smaller, a strip of bare stage shows below it; larger, it is clipped. |
Final translation (CSS — @keyframes scrollHorizontal { to { transform: translateX(-75%) } }) |
−75% of the track (−984 px for 1,312 px) | The percentage refers to the track: at the end, its last quarter stays visible (328 px), whatever the frame. -50% stops the track halfway, on panels 4 to 6. To land the end on the frame edge: translateX(calc(100cqw - 100%)) with container-type: inline-size on .hscroll-sticky (measured: last panel 16 px from the edge, at 1,264 as at 374 px wide). |
Number and size of panels (HTML + CSS — .hscroll-panel { width: 200px; height: 200px }, gap: 16px) |
6 panels of 200 × 200 px, 1,312 px track | Each added panel lengthens the track by 216 px and the translation by 162 px, nothing to recompute (measured with 7: 1,528 px track, −1,146 px). A seventh has no background: add its :nth-child(7) rule. For an end of travel landing on a frame of width W with six panels: width = (4 W − 112) ÷ 6, i.e. 341 px for 540 px. |
Progression (CSS — animation-timing-function: linear, animation-range: normal) |
linear over the whole travel | ease-in-out makes the track start and finish gently for the same scrolling. animation-range: 10% 90% keeps the track still over the first and last 84 px of travel, animation-fill-mode: both holding both states — time to read the first panel. |
Panel backgrounds (CSS — :nth-child(1) to (6)) |
six 135° gradients: indigo, pink, cyan, amber, green, violet | One gradient per panel, in DOM order; replace them with an image (background: url(…) center/cover) or a flat color. The white 19.2 px text drops below 3:1 on the light colors of panels 3, 4 and 5: darken the first color of the gradient. |
“Scrollez verticalement” hint (CSS — .slr-scroll-hint, @keyframes hintBounce) |
8 px from the bottom of .demo-preview, 11.2 px, white at 40%, 4 px bounce every 2 s |
Hard-coded French, prefix from another effect, anchored to .demo-preview: outside that frame, it sinks to the bottom of the page. Remove the span and the @keyframes hintBounce with it, or place it inside .hscroll-sticky (position: relative on the block) so it follows the area. |
Fallback without animation-timeline (CSS — @supports not (animation-timeline: scroll())) |
track laid flat, native horizontal scrolling of the block | To show all panels at once rather than a row to slide: inside that block, .hscroll-track { flex-wrap: wrap; width: auto; animation: none } and .hscroll-sticky { height: auto } — the six panels arrange into a grid. For a snapping carousel: scroll-snap-type: x mandatory on the block and scroll-snap-align: start on the panels. |
FAQ
translateX(-75%) is measured on the track, not on the frame: at the end of the travel, its last quarter always remains, 328 px for six 200 px panels. Measured at 1,264 px wide: panel 6 sits at 112 to 312 px, half of panel 5 precedes it, and 952 px are empty on the right — the track has moved “too far” for that frame; at 374 px, the same state leaves 62 px free. The value is calibrated for a frame of about 328 px, the catalog card, where the last panel lands right on the edge. Two fixes: size the panels so the track is four times the frame (341 px for 540 px, the scenes on this page), or write the end of the travel as a function of the frame, translateX(calc(100cqw - 100%)) with container-type: inline-size on .hscroll-sticky — measured, the last panel stops 16 px from the edge at 1,264 as at 374 px, without changing the scroll travel.translateX(-600px), a fixed value: a sixth card ends up 400 px from the left edge, outside a 374 px frame, and the value must be recomputed with every card added; fx-0567 writes -75% and absorbs a seventh panel without any change (measured: whole, from 166 to 366 px). fx-0564 uses the anonymous timeline animation-timeline: scroll(), which takes the first scrolling ancestor — wrap the track in another overflow: auto box and that box drives it; fx-0567 names its timeline on the area and the track finds it through any intermediary. fx-0564 pins its track at top: 50% in an 800 px stage (520 px travel) with scroll-behavior: smooth; fx-0567 pins a full 280 px block at top: 0 over 840 px, with no easing, in animation-fill-mode: both. Neither moves in Firefox; only fx-0567 provides a fallback there. Sticky Horizontal Reveal (fx-0796) is the closest neighbour: same spacer, same sticky block, same row, but moved by a script — a 1.4 KB IIFE, one scroll listener per scene, --shr-x rewritten in the DOM at every notch. Pick fx-0796 if the row must also move in Firefox, stop right on the last panel whatever their number (travel computed from the row's real width) or feed a progress bar (--shr-p); pick fx-0567 for a track with no JavaScript at all, driven by the stylesheet with no listener and no DOM write, with a Firefox fallback already in the sold CSS. Both do without libraries and images, accept several copies in the same page and move back with the wheel..hscroll-outer { height: auto; overflow: visible; scroll-timeline: none }, .demo-preview frame in display: block), give the stage the travel in screen heights and a view timeline (.hscroll-spacer { height: 400vh; view-timeline: --hsView block }), the pinned block the screen height (.hscroll-sticky { height: 100vh }), and hook the track up: .hscroll-track { animation-timeline: --hsView; animation-range: contain 0% contain 100% }. The contain range covers exactly the pinned phase: measured, the block pins at 608 px of scrolling and releases at 3,008 (three times 100vh), the track goes from 0 to −984 px over that interval (−489 px halfway) and stays there afterwards thanks to fill-mode: both. A named scroll-timeline on html would spread the travel over the whole document, content before and after included. The @supports not fallback remains valid as is.