Skip to content

Path Follow Scroll

A marker that rides an SVG curve on scroll, banking through the bends.

Freeminimaleditorialneon
Category
Animations
Added
2026-09-20
Deps
1
Updated
2026-09-20
Preview
Scroll inside ↓

Props

14
Draw trail
Bank through bends
Requires framer-motion
"use client";

import { useRef } from "react";
import { motion, useScroll, useSpring, useTransform } from "framer-motion";

export function PathFollowScroll({
  d,
  viewBox = "0 0 340 170",
  markerSize = 14,
  drawPath = true,
  autoRotate = true,
}: {
  /** Any SVG path string. The marker rides this exact curve. */
  d: string;
  viewBox?: string;
  markerSize?: number;
  /** Draw the trail in behind the marker as it travels. */
  drawPath?: boolean;
  /** Let the browser bank the marker to the curve's tangent. */
  autoRotate?: boolean;
}) {
  const sectionRef = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: sectionRef,
    offset: ["start end", "end start"],
  });
  const progress = useSpring(scrollYProgress, { stiffness: 220, damping: 40 });

  const offsetDistance = useTransform(progress, (v) => v * 100 + "%");
  // Hoisted: feeding this inline into JSX that may be conditional would make
  // a hook run conditionally.
  const dashOffset = useTransform(progress, (v) => 1 - v);

  return (
    <div ref={sectionRef} className="relative h-[200vh]">
      <div className="sticky top-1/3">
        <div className="relative mx-auto w-full max-w-3xl">
          <svg aria-hidden viewBox={viewBox} className="w-full overflow-visible">
            <path d={d} fill="none" stroke="currentColor" strokeOpacity={0.14} strokeWidth={1.5} />
            {drawPath && (
              <motion.path
                d={d}
                fill="none"
                stroke="#3d7bff"
                strokeWidth={1.5}
                strokeLinecap="round"
              /*
               * pathLength={1} normalises the path so dasharray/dashoffset are
               * plain 0-1 fractions — no getTotalLength(), and it keeps working
               * at any rendered size.
               */
                pathLength={1}
                strokeDasharray="1 1"
                style={{ strokeDashoffset: dashOffset }}
              />
            )}
          </svg>

          {/*
           * The marker rides CSS offset-path / offset-distance rather than
           * interpolated x/y. The browser solves position AND tangent angle
           * along the curve, so offsetRotate:"auto" banks it through the bends
           * for free — doing that manually means sampling and differentiating
           * the path yourself.
           */}
          <motion.div
            aria-hidden
            className="absolute left-0 top-0 rounded-full bg-blue-500"
            style={{
              width: markerSize,
              height: markerSize,
              offsetPath: 'path("' + d + '")',
              offsetDistance,
              offsetRotate: autoRotate ? "auto" : "0deg",
            }}
          />
        </div>
      </div>
    </div>
  );
}
Notes

About this animation

Moving an element along a curve is usually written as manual interpolation: sample the path, store points, lerp x and y between them, and — if you want the element to face where it is going — differentiate the samples to get a tangent. CSS `offset-path` makes all of that unnecessary. Set the path, drive `offset-distance` from zero to a hundred percent, and the browser solves position and tangent together, so `offset-rotate: auto` banks the marker through every bend at no cost. The trail behind it uses the same `pathLength` normalisation trick as this library's Stroke Draw Button: setting it to one turns dash values into plain fractions, which removes the `getTotalLength` call and keeps the drawing correct at any rendered size. Scroll progress is tied to the section's own passage through the viewport and smoothed through a spring, so a jumpy trackpad does not translate into a jittery marker. One rule that bites here more than elsewhere: hoist every `useTransform` to the top level — feeding one inline into JSX that renders conditionally makes a hook run conditionally.

Curator’s note

Built for viberdy 2.0. offset-path does the tangent maths the hard version of this component spends fifty lines on.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Pro

Reveals text one line at a time, each sliding up from behind its own clipping mask.

text-revealmaskscroll
Pro

A wrapper that reveals its children one after another as the group scrolls into view.

staggerrevealscroll
NewPro

A parallax scene where every layer derives from one shared scroll source.

scrollparallaxdepth