---
name: horizontal-scroll-section
description: Use when you want "Editorial, modern, immersive" - Turns vertical scrolling into horizontal movement for a section.
---

# Horizontal Scroll Section

> **Category:** Scroll  -  **Personality:** Editorial, modern, immersive
>
> **Best use:** Case studies, galleries, timelines
>
> **Ratings:** Professional 3/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A "horizontal scroll" section **pins a panel to the viewport and turns ordinary vertical scrolling into sideways movement** of a track, so a row of case studies, gallery images or timeline cards pans past as the reader scrolls down. It reads as editorial and immersive because the page appears to "turn" sideways without ever leaving the normal scroll flow. Reach for it on selected-works reels, product galleries and chronological timelines where lateral motion suits the content. This build is **pure CSS layout + ~30 lines of vanilla JS** (no GSAP), and a `prefers-reduced-motion` fallback degrades it to a plain vertical stack.

## Dependencies / CDN

**None for the mechanic** - `position: sticky` plus a scroll listener that sets `translateX`. (GSAP ScrollTrigger's `pin` + `scrub` is the popular library alternative, but it is not required and is heavier.) The demo only loads display fonts:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&family=Schibsted+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
```

## HTML

Four nested layers: a tall **runway** (scroll distance), a **sticky stage** (the pin), a **rail** (clips + centres), and the **track** (the flex row that moves).

```html
<section class="hs-runway">                 <!-- tall spacer; height set in JS -->
  <div class="hs-stage">                    <!-- position:sticky pin + overflow clip -->
    <div class="hs-rail">                   <!-- centres the track, hides overflow -->
      <div class="hs-track" id="hsTrack">   <!-- flex row, receives translateX -->

        <article class="hs-panel hs-w-l">
          <div class="hs-frame">
            <img class="hs-img" src="/.../landscape-coast.jpg" alt="Atlantic coastline" draggable="false">
            <span class="hs-tag">Place Branding</span>
          </div>
          <div class="hs-cap">
            <div class="hs-meta"><span>Identity &middot; Wayfinding</span><span>2024</span></div>
            <h3 class="hs-name">Northwind</h3>
            <p class="hs-blurb">Identity, signage and a booking site for a family of coastal lodges.</p>
          </div>
        </article>

        <article class="hs-panel hs-w-m"> ... </article>
        <!-- ...more panels... -->

      </div>
    </div>

    <!-- optional pinned chrome -->
    <div class="hs-bottom"><div class="hs-prog"><span class="hs-prog-fill" id="hsFill"></span></div></div>
  </div>
</section>
```

## CSS

The load-bearing rules are the sticky pin, the clipping rail, and the `width:max-content` track. Everything else is theming.

```css
.hs-runway{position:relative}                      /* runway HEIGHT is set in JS */

.hs-stage{                                          /* THE PIN */
  position:sticky; top:clamp(54px,6.5vh,74px);
  height:min(82vh,680px);
  border-radius:26px; overflow:hidden; isolation:isolate;
}
.hs-rail{position:absolute; inset:0;                /* clip + vertical centre */
  display:flex; align-items:center; overflow:hidden}
.hs-track{display:flex; width:max-content;          /* sized to its panels */
  align-items:center; gap:clamp(18px,2.2vw,30px);
  padding-inline:clamp(22px,5vw,64px); will-change:transform}

.hs-panel{flex:0 0 auto; height:min(63vh,512px); display:flex; flex-direction:column}
.hs-w-xl{width:min(86vw,540px)} .hs-w-l{width:min(80vw,500px)}
.hs-w-m{width:min(72vw,400px)}  .hs-w-s{width:min(66vw,344px)}

.hs-frame{position:relative; flex:1; min-height:0; border-radius:16px; overflow:hidden}
.hs-img{position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transform:scale(1.12)}

/* REDUCED MOTION -> plain vertical editorial stack, no pin / no scrub */
@media (prefers-reduced-motion: reduce){
  .hs-runway{height:auto!important}
  .hs-stage{position:static; height:auto}
  .hs-rail{position:static; display:block; overflow:visible}
  .hs-track{flex-direction:column; width:auto; transform:none!important; gap:34px; padding:0}
  .hs-panel{width:auto!important; height:auto; max-width:560px; margin:0 auto}
  .hs-frame{flex:none; aspect-ratio:3/2}
  .hs-img{transform:none!important}
}
```

## JavaScript

Two functions: `measure()` sets the runway height (and so the scroll budget), `render()` maps scroll progress to `translateX`. The demo layers a progress bar, a counter and per-panel image parallax on top of this core.

```js
const runway = document.querySelector('.hs-runway');
const stage  = document.querySelector('.hs-stage');
const rail   = document.querySelector('.hs-rail');
const track  = document.getElementById('hsTrack');
let dist = 0, topOffset = 0;

function measure(){
  topOffset = parseFloat(getComputedStyle(stage).top) || 0;
  dist = Math.max(0, track.scrollWidth - rail.clientWidth);   // horizontal overflow
  runway.style.height = (stage.offsetHeight + dist) + 'px';   // vertical runway == overflow (1:1)
}

function render(){
  const top = runway.getBoundingClientRect().top;
  let p = dist > 0 ? (topOffset - top) / dist : 0;            // 0..1 across the pinned range
  p = Math.min(1, Math.max(0, p));
  track.style.transform = `translate3d(${-p * dist}px,0,0)`;
  // demo extras: fill.style.width = (p*100)+'%'; counter = Math.round(p*(n-1))+1; image parallax...
}

let ticking = false;
function onScroll(){ if(!ticking){ ticking = true; requestAnimationFrame(() => { ticking = false; render(); }); } }

if(!matchMedia('(prefers-reduced-motion: reduce)').matches){
  measure(); render();
  addEventListener('scroll', onScroll, { passive:true });
  addEventListener('resize', () => { measure(); render(); });
  if(document.fonts && document.fonts.ready) document.fonts.ready.then(() => { measure(); render(); });
}
```

## How it works

- The **runway** is just a tall block; its height is the *only* thing that creates the vertical scroll distance the effect rides on.
- The **stage** is `position:sticky; top:60px`, so it sticks to the viewport while the runway keeps scrolling past it - that is the "pin".
- JS sizes the runway to **`stageHeight + horizontalOverflow`**, so the amount of stuck scrolling equals the track's overflow and the pan feels 1:1 with the wheel.
- Progress is `p = (topOffset - runway.top) / dist`, clamped to `0..1`; `translateX = -p * dist` slides the track from the first panel to the last exactly as `p` goes `0 -> 1`.
- `width:max-content` lets the flex track grow to fit its panels, and the rail's `overflow:hidden` clips the surplus so nothing spills onto the page.

## Customization

- **Speed / length:** scale the overflow when sizing the runway - `stage.offsetHeight + dist * 0.8` is faster and shorter, `* 1.4` is slower and longer.
- **Pin position:** change `.hs-stage { top }` to clear any sticky site header; JS reads it back via `getComputedStyle`, so the two never drift.
- **Panel rhythm:** alternate the width classes (`hs-w-xl/l/m/s`) for an editorial cadence, and tune `gap`.
- **Chrome:** the demo derives a progress bar (`width:p*100%`), a `nn / total` counter (`Math.round(p*(n-1))+1`), and a per-panel image **parallax** (`translateX(offsetFromCentre * -22px) scale(1.12)`) plus gentle dimming of off-centre captions - all from the same `p`.

## Accessibility & performance

- **Honour `prefers-reduced-motion`:** the media query collapses the whole thing to a normal vertical stack and the JS guard bails out - no pin, no transform.
- It is driven by **native page scroll**, so keyboard, Page-Down, spacebar and trackpad all work, and the panels are real `<article>` / headings that screen readers read in order.
- Animate **only `transform`** (compositor-friendly) and throttle writes with `requestAnimationFrame`; never animate `left`/`width`.
- Keep `will-change:transform` on the track, and re-`measure()` on `resize` and after `document.fonts.ready`, because a font swap changes `track.scrollWidth`.

## Gotchas

- **Any scrollable/clipping ancestor kills `position:sticky`** - an `overflow:hidden/auto/scroll` on a parent silently disables the pin. Keep the runway in the normal page flow.
- **The runway needs an explicit height** (set here in JS). With no extra height the stage never stays pinned and nothing scrubs.
- The CSS `top` and the JS `topOffset` **must agree** - reading it with `getComputedStyle(stage).top` is what keeps progress aligned with the pin.
- **Never `width:100vw`** on the stage or track - that overflows by the scrollbar width and adds a horizontal scrollbar. Clip with the rail's `overflow:hidden` instead.
- Measure travel as `track.scrollWidth - rail.clientWidth` (not the viewport width) so inner padding is counted and the last panel fully lands at `p = 1`.
- Give parallaxing images `scale(>1)` so the counter-translate never exposes a bare edge inside the frame.
