Rejouice — loading0

Pinned horizontal scroll

A section that pins to the viewport and translates a horizontal track sideways as the user scrolls vertically.

Grade: ARuntimes: css · gsapStatus: live
Loading demo…
Pinned horizontal scroll — vertical scroll drives lateral travelOpen standalone ↗

Intent

Gives a gallery or timeline its own axis. Because the input is still vertical scroll, it works with a trackpad, a wheel, and a touch flick without hijacking anything — the page never stops responding to normal scrolling.

Implementation

The media query is the fallback path: below 64rem and under reduced motion the same markup becomes a native snap-scroller and the script never arms.

Dependencies: none

html-css-js
<section class="pin" data-pin>
  <div class="pin__viewport">
    <div class="pin__track">
      <article class="pin__item"></article>
      <article class="pin__item"></article>
    </div>
  </div>
</section>

<style>
.pin__track {
  display: flex;
  gap: var(--grid-gap);
  padding-inline: var(--gutter);
}

.pin__item { flex: 0 0 clamp(18rem, 34vw, 32rem); }

/* Fallback first: a native horizontal snap scroller. */
.pin__viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
}

.pin__item { scroll-snap-align: start; }

@media (min-width: 64rem) and (prefers-reduced-motion: no-preference) {
  .pin.is-armed .pin__viewport {
    position: sticky;
    top: 0;
    height: 100svh;
    display: flex;
    align-items: center;
    overflow: hidden;
  }

  .pin.is-armed .pin__track {
    transform: translate3d(var(--x, 0px), 0, 0);
  }
}
</style>

<script type="module">
const enabled =
  matchMedia("(min-width: 64rem) and (prefers-reduced-motion: no-preference)").matches;

if (enabled) {
  for (const section of document.querySelectorAll("[data-pin]")) {
    const track = section.querySelector(".pin__track");
    section.classList.add("is-armed");

    // Scroll distance = however far the track has to travel sideways.
    const sync = () => {
      const overflow = Math.max(0, track.scrollWidth - innerWidth);
      section.style.height = innerHeight + overflow + "px";
      return overflow;
    };

    let overflow = sync();
    addEventListener("resize", () => { overflow = sync(); }, { passive: true });

    const frame = () => {
      const rect = section.getBoundingClientRect();
      const progress = Math.min(1, Math.max(0, -rect.top / (rect.height - innerHeight)));
      track.style.setProperty("--x", -progress * overflow + "px");
      requestAnimationFrame(frame);
    };

    requestAnimationFrame(frame);

    // Keep tabbing coherent: bring a focused off-screen panel into view.
    track.addEventListener("focusin", (event) => {
      const item = event.target.closest(".pin__item");
      if (!item) return;
      const index = [...track.children].indexOf(item);
      const target = (index / Math.max(1, track.children.length - 1)) * overflow;
      scrollTo({ top: section.offsetTop + target, behavior: "smooth" });
    });
  }
}
</script>

Use it when

  • Project galleries, timelines, and process steps where lateral adjacency is meaningful.
  • When you have four to eight items of similar weight.

Avoid it when

  • For primary navigation or anything a user needs to find quickly.
  • On mobile, where a native horizontal swipe with scroll-snap is better than a pinned translation.

Accessibility

  • Keyboard users tabbing into an off-screen panel must have it brought into view. Listen for `focusin` on the track and scroll the window to the matching vertical position.
  • Provide a native horizontal-scroll fallback below the tablet breakpoint and under reduced motion, using `overflow-x: auto` and `scroll-snap-type: x mandatory`.
  • Do not remove the wrapper's scroll distance — screen-reader virtual cursors rely on the content being in normal flow.

Performance

  • Compute translation inside a rAF loop from a cached `getBoundingClientRect`, not on every scroll event.
  • Use `translate3d` and set `will-change: transform` on the track only while the section is in view.

Knobs

items
Number of panels in the track. Default: 6
item width
Width of each panel. Default: clamp(18rem, 34vw, 32rem)

Composition

Instruction for Claude Code

Build a pinned horizontal scroll section. Author the CSS fallback first: the viewport is overflow-x auto with scroll-snap-type x mandatory and snap-aligned items. Only inside a (min-width: 64rem) and (prefers-reduced-motion: no-preference) query, and only when JavaScript has added an is-armed class, make the viewport position sticky at 100svh with overflow hidden and translate the track by a --x custom property. Set the section height to innerHeight plus track overflow, recompute on resize, derive progress from getBoundingClientRect inside a rAF loop, and add a focusin handler that scrolls a focused off-screen panel into view.