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…
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
<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
Pairs with
Conflicts with
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.