Horizontal Scroll Section
Scrolling down pins the section and slides a row of panels sideways, then lets the page continue once the row ends.
Create a horizontal scroll section driven by vertical scroll, in React and CSS, without a scroll library. Make an outer section several times the viewport height (4 panels → 400vh) and inside it a position: sticky; top: 0 wrapper of exactly one viewport height with overflow hidden. Inside the wrapper, a flex track holds the panels side by side. On scroll, compute progress as the distance scrolled into the section divided by (section height − viewport height), clamped 0 to 1, and set the track transform to translateX(−progress × (track width − viewport width)) via a CSS custom property, so it never re-renders React. Give each panel a large number and a label. Under prefers-reduced-motion, lay the panels out as a normal vertical stack instead. The panels read "Plan", "Design", "Build", and "Launch".
Source
'use client'
import { useRef } from 'react'
import s from './demo.module.css'
export default function HorizontalScroll({ labels }: { labels: Record<string, string> }) {
const track = useRef<HTMLDivElement>(null)
const panels = labels.panels.split('|')
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const t = track.current
if (!t) return
const p = Math.min(Math.max(el.scrollTop / (el.scrollHeight - el.clientHeight || 1), 0), 1)
t.style.setProperty('--x', `${-p * (t.scrollWidth - el.clientWidth)}px`)
}
return (
<div className={s.frame} tabIndex={0} onScroll={onScroll} style={{ '--n': panels.length } as React.CSSProperties}>
<div className={s.section}>
<div className={s.sticky}>
<span className={s.label}>{labels.hint} ↓</span>
<div ref={track} className={s.track}>
{panels.map((p, i) => (
<div key={p} className={s.panel}>
<span className={s.n}>{String(i + 1).padStart(2, '0')}</span>
<strong className={s.name}>{p}</strong>
</div>
))}
</div>
</div>
</div>
</div>
)
}
.frame {
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.frame:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -4px; }
.hint {
display: block;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
.section { height: calc(var(--n) * 100%); }
.sticky {
position: sticky;
top: 0;
display: grid;
align-content: center;
gap: 12px;
height: 240px;
overflow: hidden;
}
.label {
padding-left: 20px;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stage-muted);
}
.track {
--x: 0px;
display: flex;
gap: 12px;
width: max-content;
padding: 0 20px;
transform: translateX(var(--x));
}
.panel {
display: grid;
align-content: space-between;
width: 180px;
height: 140px;
padding: 14px;
border: 1px solid var(--stage-rule);
border-radius: 14px;
background: var(--stage-surface);
}
.panel:nth-child(even) { background: var(--stage-ink); }
.panel:nth-child(even) .name { color: var(--stage-bg); }
.n { font: 500 var(--text-sm) / 1 var(--font-mono); color: var(--stage-accent); }
.name { font: 600 var(--text-2xl) / 1 var(--font-display); color: var(--stage-ink); }
@media (prefers-reduced-motion: reduce) {
.section { height: auto; }
.sticky { position: static; height: auto; padding: 16px 0; }
.track { flex-direction: column; width: auto; transform: none; }
.panel { width: auto; }
}