视差分层
滚动时山丘、太阳和标题以不同速度移动,让一幅平面画面产生纵深。
走进群山
向下滚动 ↓用 React 和 CSS 制作分层视差场景。在一个 sticky 场景中叠放四个绝对定位的图层:带太阳的天空、远山、近山和标题。滚动时计算区块内 0 到 1 的进度,写入场景的 CSS 自定义属性 --p(不使用 React state)。每个图层以各自的系数移动:transform: translateY(calc(var(--p) * <n>px)),越远的图层移动越少(太阳 60px、远山 30px、近山 -20px),标题向上漂移并淡出(translateY(calc(var(--p) * -80px)),opacity calc(1 - var(--p) * 1.4))。山丘用 clip-path 多边形或 radial-gradient 绘制。图层设置 will-change: transform。在 prefers-reduced-motion 下所有图层保持静止。标题为“走进群山”。
源代码
'use client'
import s from './demo.module.css'
export default function ParallaxLayers({ labels }: { labels: Record<string, string> }) {
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
el.style.setProperty('--p', String(el.scrollTop / (el.scrollHeight - el.clientHeight || 1)))
}
return (
<div className={s.frame} tabIndex={0} onScroll={onScroll}>
<div className={s.section}>
<div className={s.scene}>
<span className={s.sun} />
<span className={s.far} />
<p className={s.title}>{labels.title}</p>
<span className={s.near} />
<span className={s.label}>{labels.hint} ↓</span>
</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);
}
.frame { --p: 0; }
.section { height: 300%; }
.scene {
position: sticky;
top: 0;
height: 240px;
overflow: hidden;
background: linear-gradient(oklch(34% 0.06 280), oklch(62% 0.11 45));
}
.sun, .far, .near, .title { position: absolute; will-change: transform; }
.sun {
left: 60%;
top: 30%;
width: 54px;
height: 54px;
border-radius: 50%;
background: oklch(90% 0.09 80);
transform: translateY(calc(var(--p) * 60px));
}
.far {
inset: 45% -10% -10%;
background: oklch(40% 0.07 30);
clip-path: polygon(0 40%, 18% 10%, 34% 35%, 52% 0, 70% 30%, 86% 8%, 100% 30%, 100% 100%, 0 100%);
transform: translateY(calc(var(--p) * 30px));
}
.near {
inset: 68% -10% -20%;
background: var(--stage-bg);
clip-path: polygon(0 30%, 22% 0, 40% 25%, 62% 5%, 80% 30%, 100% 10%, 100% 100%, 0 100%);
transform: translateY(calc(var(--p) * -20px));
}
.title {
left: 0;
right: 0;
top: 18%;
margin: 0;
text-align: center;
font: 700 var(--text-2xl) / 1 var(--font-display);
color: var(--stage-ink);
transform: translateY(calc(var(--p) * -80px));
opacity: calc(1 - var(--p) * 1.4);
}
.label {
position: absolute;
left: 0;
right: 0;
bottom: 12px;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.sun, .far, .near, .title { transform: none; opacity: 1; }
}