A pinned SVG route draws itself in a two-color gradient as you scroll a smooth-scroll viewport, led by a glowing head dot, while milestone markers light up and their labels rise into place the moment the line reaches them.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-path-drawWrites components/ui/scroll-path-draw.tsx and components/styles/scroll-path-draw.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ScrollPathDraw } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollPathDraw />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native) with a 2px :focus-visible outline. The SVG is role="img" with an aria-label listing every milestone in order, and the milestone labels are real SVG text; the head dot and percentage readout are aria-hidden. Wheel input is smoothed only while the viewport can still move and is released at the edges so the page keeps scrolling; touch and scrollbar dragging stay native. Under prefers-reduced-motion the wheel is not intercepted and the route renders fully drawn with every milestone visible.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-path-draw.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 const overflowY = window.getComputedStyle(el).overflowY;42 if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {43 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;44 }45 }46 el = el.parentElement;47 }48 return false;49}50 51/**52 * Self-contained smooth scroller for an overflow container: wheel input sets a target and a rAF53 * loop eases scrollTop toward it. Touch, keyboard and scrollbar stay native; wheel is released at54 * the edges so the page keeps scrolling.55 */56function useSmoothScroll(57 containerRef: React.RefObject<HTMLElement | null>,58 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,59) {60 const callbackRef = React.useRef(onScroll);61 React.useEffect(() => {62 callbackRef.current = onScroll;63 }, [onScroll]);64 65 React.useEffect(() => {66 const el = containerRef.current;67 if (!el) return;68 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);69 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;70 let target = el.scrollTop;71 let current = target;72 let raf = 0;73 let lastTime = 0;74 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);75 const emit = () => {76 const limit = getLimit();77 const scroll = clamp(el.scrollTop, 0, limit);78 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });79 };80 const tick = (time: number) => {81 const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;82 lastTime = time;83 target = clamp(target, 0, getLimit());84 if (Math.abs(el.scrollTop - current) > 2) {85 current = target = el.scrollTop;86 raf = 0;87 lastTime = 0;88 emit();89 return;90 }91 current += (target - current) * (1 - Math.pow(1 - ease, dt / 16.667));92 if (Math.abs(target - current) < 0.35) current = target;93 el.scrollTop = current;94 emit();95 if (current === target) {96 raf = 0;97 lastTime = 0;98 return;99 }100 raf = requestAnimationFrame(tick);101 };102 const onWheel = (event: WheelEvent) => {103 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;104 let dy = event.deltaY;105 if (event.deltaMode === 1) dy *= 16;106 else if (event.deltaMode === 2) dy *= el.clientHeight;107 dy *= multiplier;108 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;109 const limit = getLimit();110 if (!raf) current = target = el.scrollTop;111 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;112 event.preventDefault();113 target = clamp(target + dy, 0, limit);114 if (!raf) {115 lastTime = 0;116 raf = requestAnimationFrame(tick);117 }118 };119 const onNativeScroll = () => {120 if (raf) return;121 current = target = el.scrollTop;122 emit();123 };124 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });125 el.addEventListener("scroll", onNativeScroll, { passive: true });126 const ro = new ResizeObserver(emit);127 ro.observe(el);128 if (el.firstElementChild) ro.observe(el.firstElementChild);129 emit();130 return () => {131 if (smooth) el.removeEventListener("wheel", onWheel);132 el.removeEventListener("scroll", onNativeScroll);133 ro.disconnect();134 if (raf) cancelAnimationFrame(raf);135 };136 }, [containerRef, lerp, wheelMultiplier, smooth]);137}138 139export interface ScrollPathDrawProps {140 /** Milestone labels placed along the route, in order */141 milestones?: string[];142 /** Heading shown above the route */143 title?: string;144 /** Gradient start color of the drawn path */145 color1?: string;146 /** Gradient end color of the drawn path */147 color2?: string;148 /** Stroke width of the route in SVG units (viewBox is 1000 wide) */149 strokeWidth?: number;150 /** Scroll distance as a multiple of the viewport height (more = slower drawing) */151 scrollLength?: number;152 /** Glow around the drawn path and head dot */153 glow?: boolean;154 /** Viewport height in px */155 height?: number;156 /** Viewport corner radius in px */157 borderRadius?: number;158 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */159 lerp?: number;160 /** Multiplier applied to wheel / trackpad deltas */161 wheelMultiplier?: number;162 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */163 smooth?: boolean;164 /** Optional className merged onto the root */165 className?: string;166 /** Inline styles merged onto the root */167 style?: React.CSSProperties;168}169 170const DEFAULT_MILESTONES = ["Kickoff", "Prototype", "Private beta", "Public launch", "Series A"];171 172const rand = (n: number) => {173 const x = Math.sin(n * 9301 + 49297) * 233280;174 return x - Math.floor(x);175};176 177interface Point {178 x: number;179 y: number;180}181 182/** Builds a monotonic-in-x winding route through the milestone anchors. */183function buildRoute(count: number): { d: string; anchors: Point[] } {184 const n = Math.max(1, count);185 const anchors: Point[] = Array.from({ length: n }, (_, i) => ({186 x: n === 1 ? 500 : 110 + (i * 780) / (n - 1),187 y: (i % 2 === 0 ? 268 : 112) + (rand(i + 3) - 0.5) * 36,188 }));189 const pts: Point[] = [{ x: 18, y: 330 }, ...anchors, { x: 982, y: n % 2 === 0 ? 300 : 70 }];190 let d = `M ${pts[0].x} ${pts[0].y}`;191 for (let i = 1; i < pts.length; i++) {192 const a = pts[i - 1];193 const b = pts[i];194 const h = (b.x - a.x) / 2;195 d += ` C ${(a.x + h).toFixed(1)} ${a.y.toFixed(1)} ${(b.x - h).toFixed(1)} ${b.y.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;196 }197 return { d, anchors };198}199 200export function ScrollPathDraw({201 milestones = DEFAULT_MILESTONES,202 title = "The road so far",203 color1 = "#a78bfa",204 color2 = "#22d3ee",205 strokeWidth = 5,206 scrollLength = 3,207 glow = true,208 height = 380,209 borderRadius = 20,210 lerp = 0.1,211 wheelMultiplier = 1,212 smooth = true,213 className = "",214 style,215}: ScrollPathDrawProps) {216 const reduced = usePrefersReducedMotion();217 const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");218 const viewportRef = React.useRef<HTMLDivElement>(null);219 const rootRef = React.useRef<HTMLDivElement>(null);220 const pathRef = React.useRef<SVGPathElement>(null);221 const headRef = React.useRef<SVGGElement>(null);222 const percentRef = React.useRef<HTMLSpanElement>(null);223 const markerRefs = React.useRef<(SVGGElement | null)[]>([]);224 const fractionsRef = React.useRef<number[]>([]);225 const progressRef = React.useRef(0);226 227 const { d, anchors } = React.useMemo(() => buildRoute(milestones.length), [milestones.length]);228 229 const apply = React.useCallback(() => {230 const path = pathRef.current;231 if (!path) return;232 const p = reduced ? 1 : clamp(progressRef.current / 0.94, 0, 1);233 const total = path.getTotalLength();234 path.style.strokeDashoffset = String(1 - p);235 const pt = path.getPointAtLength(total * p);236 headRef.current?.setAttribute("transform", `translate(${pt.x.toFixed(2)} ${pt.y.toFixed(2)})`);237 markerRefs.current.forEach((m, i) => {238 if (m) m.dataset.active = String(p >= (fractionsRef.current[i] ?? 1) - 0.004);239 });240 if (percentRef.current) percentRef.current.textContent = `${Math.round(p * 100)}%`;241 rootRef.current?.style.setProperty("--mf-spd-p", p.toFixed(4));242 }, [reduced]);243 244 React.useEffect(() => {245 const path = pathRef.current;246 if (!path) return;247 const total = path.getTotalLength();248 fractionsRef.current = anchors.map((a) => {249 let lo = 0;250 let hi = total;251 for (let k = 0; k < 24; k++) {252 const mid = (lo + hi) / 2;253 if (path.getPointAtLength(mid).x < a.x) lo = mid;254 else hi = mid;255 }256 return total > 0 ? hi / total : 0;257 });258 apply();259 }, [anchors, d, apply]);260 261 const handleScroll = React.useCallback(262 (state: SmoothScrollState) => {263 progressRef.current = state.progress;264 apply();265 },266 [apply],267 );268 269 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });270 271 const vars = {272 "--mf-spd-h": `${height}px`,273 "--mf-spd-r": `${borderRadius}px`,274 "--mf-spd-c1": color1,275 "--mf-spd-c2": color2,276 "--mf-spd-len": Math.max(1.2, scrollLength),277 ...style,278 } as React.CSSProperties;279 const filter = glow ? `url(#${uid}-glow)` : undefined;280 281 return (282 <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>283 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>284 <div className={styles.track}>285 <div className={styles.stage}>286 <header className={styles.header}>287 <h3 className={styles.title}>{title}</h3>288 <span className={styles.percent} ref={percentRef} aria-hidden="true">289 0%290 </span>291 </header>292 <svg className={styles.svg} viewBox="0 0 1000 400" preserveAspectRatio="xMidYMid meet" role="img"293 aria-label={`Route through ${milestones.join(", ")}`}>294 <defs>295 <linearGradient id={`${uid}-grad`} x1="0" y1="0" x2="1000" y2="0" gradientUnits="userSpaceOnUse">296 <stop offset="0" stopColor={color1} />297 <stop offset="1" stopColor={color2} />298 </linearGradient>299 <filter id={`${uid}-glow`} x="-20%" y="-60%" width="140%" height="220%">300 <feGaussianBlur stdDeviation="6" result="b" />301 <feMerge>302 <feMergeNode in="b" />303 <feMergeNode in="SourceGraphic" />304 </feMerge>305 </filter>306 </defs>307 <path d={d} className={styles.base} strokeWidth={Math.max(1, strokeWidth * 0.5)} />308 <path ref={pathRef} d={d} className={styles.line} pathLength={1} stroke={`url(#${uid}-grad)`}309 strokeWidth={strokeWidth} filter={filter} />310 {anchors.map((a, i) => {311 const above = a.y > 200;312 return (313 <g key={`${milestones[i]}-${i}`} ref={(node) => { markerRefs.current[i] = node; }}314 className={styles.marker} data-active="false" transform={`translate(${a.x.toFixed(1)} ${a.y.toFixed(1)})`}>315 <circle r={strokeWidth + 5} className={styles.ring} />316 <circle r={Math.max(3, strokeWidth * 0.6)} className={styles.pin} />317 <g className={styles.label} style={{ "--mf-spd-dy": above ? "8px" : "-8px" } as React.CSSProperties}>318 <text y={above ? -30 : 44} textAnchor="middle" className={styles.step}>319 {`0${i + 1}`.slice(-2)}320 </text>321 <text y={above ? -54 : 70} textAnchor="middle" className={styles.name}>322 {milestones[i]}323 </text>324 </g>325 </g>326 );327 })}328 <g ref={headRef} className={styles.head} aria-hidden="true" transform="translate(18 330)">329 <circle r={strokeWidth + 9} className={styles.halo} filter={filter} />330 <circle r={strokeWidth + 2} className={styles.dot} />331 </g>332 </svg>333 <p className={styles.hint} aria-hidden="true">Scroll to trace the route</p>334 </div>335 </div>336 </div>337 </div>338 );339}