横向滚动区块
向下滚动时区块被固定,一排面板横向滑过,滑到尽头后页面才继续往下。
向下滚动 ↓
01规划
02设计
03开发
04上线
用 React 和 CSS 制作由纵向滚动驱动的横向滚动区块,不使用滚动库。外层 section 高度为视口高度的若干倍(4 个面板 → 400vh),内部是一个 position: sticky; top: 0、高度正好一个视口、overflow hidden 的容器。容器内用 flex 轨道把面板并排放置。滚动时计算进度:已滚入区块的距离除以(区块高度 − 视口高度),并限制在 0 到 1 之间,通过 CSS 自定义属性把轨道的 transform 设为 translateX(−进度 × (轨道宽度 − 视口宽度)),避免 React 重新渲染。每个面板显示一个大号数字和一个标签。在 prefers-reduced-motion 下改为普通的纵向堆叠布局。面板文字为“规划”“设计”“开发”“上线”。
源代码
'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; }
}