滚动绘制路径
路线随着滚动一点点画出来,往回滚又会倒退,把进度交到读者手里。
向下滚动 ↓
用 React 制作随滚动同步绘制的 SVG 路径。给路径设置 pathLength="1",这样无论实际长度多少,stroke-dasharray: 1 和 stroke-dashoffset: 1 都能把它完全隐藏。滚动时计算区块内 0 到 1 的进度,写入 CSS 自定义属性 --p;在路径上设置 stroke-dashoffset: calc(1 - var(--p)),向下滚动时向前绘制,向上滚动时倒退。区块滚动期间让 SVG 保持在 sticky 容器中。在下方画一条淡色的同样路径作为轨道,进度达到 1 时终点圆点被填满。在 prefers-reduced-motion 下直接显示完整路径。两个端点文字为“起点”和“终点”。
源代码
'use client'
import s from './demo.module.css'
const D = 'M20 150 C 70 150, 60 60, 120 70 S 170 150, 220 110 S 270 30, 300 40'
export default function ScrollDraw({ labels }: { labels: Record<string, string> }) {
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const p = el.scrollTop / (el.scrollHeight - el.clientHeight || 1)
el.style.setProperty('--p', String(p))
el.toggleAttribute('data-done', p > 0.99)
}
return (
<div className={s.frame} tabIndex={0} onScroll={onScroll}>
<div className={s.section}>
<div className={s.sticky}>
<svg viewBox="0 0 320 180" className={s.svg} aria-hidden="true">
<path d={D} className={s.track} />
<path d={D} className={s.path} pathLength={1} />
<circle cx="20" cy="150" r="5" className={s.startDot} />
<circle cx="300" cy="40" r="6" className={s.endDot} />
<text x="20" y="172" className={s.text}>
{labels.start}
</text>
<text x="300" y="22" className={s.text} textAnchor="end">
{labels.end}
</text>
</svg>
<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%; }
.sticky {
position: sticky;
top: 0;
display: grid;
place-items: center;
align-content: center;
gap: 4px;
height: 240px;
}
.svg { width: min(300px, 90%); overflow: visible; }
.track, .path {
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
.track { stroke: var(--stage-rule); stroke-dasharray: 2 8; }
.path {
stroke: var(--stage-accent);
stroke-dasharray: 1;
stroke-dashoffset: calc(1 - var(--p));
}
.startDot { fill: var(--stage-accent); }
.endDot {
fill: var(--stage-bg);
stroke: var(--stage-accent);
stroke-width: 2;
transition: fill var(--dur-short);
}
.frame[data-done] .endDot { fill: var(--stage-accent); }
.text { fill: var(--stage-muted); font: 500 11px var(--font-mono); }
.label {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.path { stroke-dashoffset: 0; }
}