Path Follow Scroll
A marker that rides an SVG curve on scroll, banking through the bends.
- Category
- Animations
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Props
"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>
);
}
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.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Line Mask Reveal
animationsReveals text one line at a time, each sliding up from behind its own clipping mask.
Stagger Reveal Group
animationsA wrapper that reveals its children one after another as the group scrolls into view.
Scroll Depth Layers
animationsA parallax scene where every layer derives from one shared scroll source.