ScrollScroll Animationsno dependencies

ScrollPathDraw

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

The road so far

01Kickoff02Prototype03Private beta04Public launch05Series A

Customize

9 props
5
3
0.1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add scroll-path-draw

Writes 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.

example.tsx
1import { ScrollPathDraw } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ScrollPathDraw />6  );7}

Props

PropTypeDefault
milestones

Milestone labels placed along the route, in order.

string[]['Kickoff', 'Prototype', 'Private beta', 'Public launch', 'Series A']
title

Heading shown above the route (also the region label).

string'The road so far'
color1

Gradient start color of the drawn path.

string'#a78bfa'
color2

Gradient end color of the drawn path, also used for the head dot and active markers.

string'#22d3ee'
strokeWidth

Stroke width of the route in SVG units (the viewBox is 1000 wide).

number5
scrollLength

Total scroll height as a multiple of the viewport height; larger values draw more slowly.

number3
glow

Adds a soft blur glow to the drawn path and head dot.

booleantrue
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/scroll-path-draw.tsx
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}