Scroll Draw Path
A route line draws itself as you scroll and rewinds when you scroll back, tying progress to the reader’s hand.
Create an SVG path that draws in step with scroll, in React. Give the path pathLength="1" so stroke-dasharray: 1 and stroke-dashoffset: 1 hide it fully regardless of its real length. On scroll, compute progress 0 to 1 through the section and write it into a CSS custom property --p; set stroke-dashoffset: calc(1 - var(--p)) on the path so it draws forward on scroll down and rewinds on scroll up. Keep the SVG in a sticky container while the section scrolls. Draw a faint copy of the path underneath as the track, and add a dot that fills when progress reaches 1. Under prefers-reduced-motion, show the full path. The endpoints read "Start" and "Finish".
Source
'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; }
}