Scroll✨ Premium

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.

CSSscroll-timelineHorizontal

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

Making an AOP cheese, step by step — a dairy's website — Horizontal Scroll Section example 1

① Making an AOP cheese, step by step — a dairy's website

When“From milking to ripening” page: six panels (milking, curdling, moulding, salting, ripening, shipping) move from right to left while the visitor scrolls down.
WhyThe cheesemaker will add a step next year: the percentage translation adjusts to the track width (measured, a seventh panel arrives whole at the end of the travel), whereas fx-0564 would leave added panels out of frame with its fixed −600 px. And left-to-right reading is how a process reads: each step pushes the previous one out.
Settings304 px area under a 56 px header — .hscroll-outer and .hscroll-sticky at the same height, spacer kept at 400% (912 px travel) — and 341 × 218 px panels: the track is 2,158 px, four times the 540 px frame, and the code's −75% lands the last panel on the right edge. Cream background, paper cards, serif headings, a ripening gauge growing from 14 to 100% from one panel to the next; tabindex="0" and aria-label on the area.
A night train's consist, car by car — a railway company's booking page — Horizontal Scroll Section example 2

② A night train's consist, car by car — a railway company's booking page

When“On board” page: the cars (locomotive, couchettes, cabins, bar car, reclining seats, luggage van) scroll horizontally like the train, on the customer's vertical gesture.
WhyA train reads from left to right: the pinned track moves it forward on the customer's vertical gesture — fx-0541 Horizontal Scroll (published) is native horizontal scrolling that requires a sideways swipe, awkward with a wheel. Here the wheel, Arrow Down or the thumb move the train forward, and scrolling back makes it reverse.
Settings308 px area under a 52 px header, 341 × 150 px cars: wheels as ::before and ::after (the sold code leaves those pseudo-elements untouched), windows as a row of i, the locomotive singled out by a class rather than :nth-child(1), a fixed rail placed outside the area so the wheels ride on it; gap reduced to 14 px, 2,148 px track.
The orchestra's stage plan, section by section — “Discover the orchestra” page of a philharmonic — Horizontal Scroll Section example 3

③ The orchestra's stage plan, section by section — “Discover the orchestra” page of a philharmonic

When“Discover the orchestra” page: first violins, second violins, woodwinds, brass, percussion, lower strings — six sections that scroll from left to right in the order they sit on stage, from house left to house right, seen from the hall, while the subscriber scrolls down.
WhyA stage plan is a layout, not a sequence: the subscriber goes from the first violins to the percussion, comes back to compare horns and trumpets, and the pinned track follows their gesture both ways — wheel, thumb or arrow keys — then holds the lower strings at the end of the travel (animation-fill-mode: both). The sticky block does not move while the sections slide: the stage edge — house left, conductor, house right — stays a fixed landmark under the cards, and it is what gives the position. The timeline named on the area allows two plans on the same page, symphony orchestra and chamber orchestra, each driven by its own scrolling (measured: two independent copies), and in Firefox the @supports fallback lays the sections flat, from house left to house right — a plan that still reads as a plan.
Settings302 px area under a 58 px header, 341 × 200 px panels with a 5 px left rule in the family's color — strings, woodwinds, brass, percussion, --fam variable set by :nth-child — carrying the place on stage, the headcount, one sentence, the neighbouring section and a 54 × 32 px miniature stage plan whose section zone is a ::before placed by four variables; stage edge (house left, conductor, house right) absolutely positioned inside the pinned block, under the track, hence motionless while the cards slide; light background, 12 px text on white.

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 not block (spacer to auto, sticky to static + 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 1px ring); 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 without tabindex="0": add it on .hscroll-outer, with an aria-label and a :focus-visible style. With a finger, the gesture must start inside the 280 px of the area; overscroll-behavior: contain keeps 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 (h3 or h4) and the area an aria-label. The hint “Scrollez verticalement” is read in French whatever the page language: aria-hidden="true". In the fallback without animation-timeline, the .hscroll-sticky block is what scrolls horizontally: it needs the same tabindex="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.

Chrome 115+✓ Full
Firefox (all versions)✓ Fallback: track laid flat with native horizontal scrolling, nothing animates on scroll
Safari 26+✓ Full
Edge 115+✓ Full
Mobile iOS 26+✓ Full (the finger scrolls the area)
Android Chrome 115+✓ Full

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):

index.html — structure
<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>
Unlock the full code — €9.90/mo excl. tax

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 / propertyDefaultEffect
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

Because 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.
Same family, four opposite choices. fx-0564 lines up five 180 px cards and pushes them by 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.
Yes, by replacing the scroll timeline with a view timeline on the stage — measured on a page with 600 px of content before and after, 800 px viewport. Neutralize the area (.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.