Follow SVG Path on Scroll
Animates a stack of items along a custom SVG path as the user scrolls, using GSAP MotionPathPlugin and ScrollTrigger. Each item fades, blurs, scales, and reveals its child content at precise scroll positions. Supports responsive path scaling and debounced resize handling.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/MotionPathPlugin.min.js"></script>Code
<div data-motionpath="wrap" class="motionpath-wrap">
<div class="motionpath-content">
<h1 class="motionpath-content-title">SPACES</h1>
<div class="motionpath-content-inner">
<div class="motionpath-content-path">
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 1366 603" fill="transparent" preserveaspectratio="none" class="motionpath-svg">
<path data-motionpath="path" d="M1115.94 0C1297.33 38.9693 1626.89 444.65 993.816 562.057C407.372 670.816 89.0772 533.413 0 436.157" stroke="transparent"></path>
</svg>
</div>
<div class="motionpath-content-wrap">
<div class="motionpath-content-list">
<div data-motionpath="item" class="motionpath-content-item">
<div class="motionpath-content-item__visual">
<img src="https://cdn.prod.website-files.com/684fe83006c8046ffc2d3616/685bbbdbe72f870106909210_Minimalist%20Terracotta%20Room.avif" class="motionpath-content-item__img">
</div>
<div data-motionpath="item-details" class="motionpath-content-item__details">
<span class="motionpath-content-item__label">Image 001</span>
<h3 class="motionpath-content-item__title">Master bedroom</h3>
</div>
</div>
<div data-motionpath="item" class="motionpath-content-item">
<div class="motionpath-content-item__visual">
<img src="https://cdn.prod.website-files.com/684fe83006c8046ffc2d3616/685bbbdb2a4dda2ba7fc3147_Terracotta%20Dining%20Room.avif" class="motionpath-content-item__img">
</div>
<div data-motionpath="item-details" class="motionpath-content-item__details">
<span class="motionpath-content-item__label">Image 002</span>
<h3 class="motionpath-content-item__title">Dining room</h3>
</div>
</div>
<div data-motionpath="item" class="motionpath-content-item">
<div class="motionpath-content-item__visual">
<img src="https://cdn.prod.website-files.com/684fe83006c8046ffc2d3616/685bbbdbe72f870106909213_Terracotta%20Kitchen%20Design.avif" class="motionpath-content-item__img">
</div>
<div data-motionpath="item-details" class="motionpath-content-item__details">
<span class="motionpath-content-item__label">Image 003</span>
<h3 class="motionpath-content-item__title">Open kitchen</h3>
</div>
</div>
<div data-motionpath="item" class="motionpath-content-item">
<div class="motionpath-content-item__visual">
<img src="https://cdn.prod.website-files.com/684fe83006c8046ffc2d3616/685bbbde57b0bf84b0d815c1_Luxurious%20Terracotta%20Bathroom.avif" class="motionpath-content-item__img">
</div>
<div data-motionpath="item-details" class="motionpath-content-item__details">
<span class="motionpath-content-item__label">Image 004</span>
<h3 class="motionpath-content-item__title">Spacious bathroom</h3>
</div>
</div>
<div data-motionpath="item" class="motionpath-content-item">
<div class="motionpath-content-item__visual">
<img src="https://cdn.prod.website-files.com/684fe83006c8046ffc2d3616/685bbbdbdfeae088022b8ee7_Minimalist%20Terracotta%20Room%20(1).avif" class="motionpath-content-item__img">
</div>
<div data-motionpath="item-details" class="motionpath-content-item__details">
<span class="motionpath-content-item__label">Image 005</span>
<h3 class="motionpath-content-item__title">Terrace</h3>
</div>
</div>
</div>
</div>
</div>
</div>
</div>.motionpath-wrap {
width: 100%;
height: 450vh;
position: relative;
overflow: clip;
}
.motionpath-container {
width: 100%;
margin-left: auto;
margin-right: auto;
}
.motionpath-content {
justify-content: center;
align-items: center;
width: 100%;
height: 100vh;
display: flex;
position: sticky;
top: 0;
}
.motionpath-content-inner {
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
display: flex;
}
.motionpath-content-path {
width: 100vmax;
height: 100%;
max-height: 45vh;
}
.motionpath-svg {
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.motionpath-content-wrap {
z-index: 1;
position: absolute;
}
.motionpath-content-item {
grid-column-gap: 2em;
grid-row-gap: 2em;
justify-content: flex-start;
align-items: flex-start;
display: flex;
position: absolute;
}
.motionpath-content-item__visual {
aspect-ratio: 1 / 1.5;
border-radius: .75em;
flex: none;
width: max(24vw, 12rem);
overflow: hidden;
}
.motionpath-content-item__img {
object-fit: cover;
width: 100%;
height: 100%;
}
.motionpath-content-item__details {
grid-column-gap: .75em;
grid-row-gap: .75em;
white-space: nowrap;
flex-flow: column;
justify-content: flex-end;
align-items: flex-start;
padding-top: 2em;
display: flex;
}
.motionpath-content-item__title {
margin-top: 0;
margin-bottom: 0;
font-size: 2em;
font-weight: 500;
line-height: 1;
}
.motionpath-content-item__label {
color: #13131399;
background-color: #fff;
border-radius: 100em;
padding: .5em .75em;
font-size: .75em;
font-weight: 500;
line-height: 1.2;
}
.motionpath-content-title {
z-index: 0;
color: #1313130f;
margin-top: 0;
margin-bottom: 0;
font-family: PP Neue Corp Tight, Arial, sans-serif;
font-size: 30vw;
line-height: .8;
position: absolute;
}
@media screen and (max-width: 767px) {
.motionpath-content-inner {
justify-content: flex-start;
align-items: flex-start;
}
.motionpath-content-path {
max-height: 70vh;
}
.motionpath-content-item {
grid-column-gap: 1em;
grid-row-gap: 1em;
}
.motionpath-content-item__details {
grid-column-gap: .5em;
grid-row-gap: .5em;
padding-top: 1em;
}
.motionpath-content-item__title {
font-size: 1.25em;
}
.motionpath-content-item__label {
font-size: .625em;
}
}gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
function initImagesOnPathScroll() {
const wrap = document.querySelector('[data-motionpath="wrap"]');
const path = wrap.querySelector('[data-motionpath="path"]');
const items = wrap.querySelectorAll('[data-motionpath="item"]');
const itemDetails = wrap.querySelectorAll('[data-motionpath="item-details"]')
// Set z-index on items, to make sure the 1st item is on top
gsap.set(items, {
zIndex: (i, target, all) => all.length - i
});
// if there's an old timeline, grab its progress, reset it, then kill it
const oldTl = initImagesOnPathScroll.tl;
let progress = 0;
if (oldTl) {
progress = oldTl.progress();
oldTl.progress(0).kill();
}
// create a new timeline + ScrollTrigger
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrap,
start: 'top top',
end: 'bottom bottom',
scrub: true
},
defaults: {
ease: 'none',
stagger: 0.3 // Define the space between each item
}
});
tl.to(items, {
duration: 1,
motionPath: { path, align: path, curviness: 2, alignOrigin: [0.5, 0.5] }
})
.fromTo(items,
{ autoAlpha: 0, },
{ autoAlpha: 1, duration: 0.1 },
0
)
.fromTo(items,
{ filter: 'blur(1.5em)' },
{ filter: 'blur(0em)', duration: 0.5 },
0
)
.fromTo(itemDetails,
{ autoAlpha: 0, yPercent:25 },
{ autoAlpha: 1, yPercent:0, duration: 0.1,},
0.5
)
.fromTo(items,
{ scale: 0.4 },
{ scale: 1, duration: 0.65 },
0
)
.to(items, { autoAlpha: 0, filter: 'blur(1em)', duration: 0.15 }, 0.85)
.to(itemDetails, { autoAlpha: 0, duration: 0.05 }, 0.9)
// jump back to previous spot and refresh
tl.progress(progress);
ScrollTrigger.refresh();
// store it on the function so we can grab it next time
initImagesOnPathScroll.tl = tl;
// on first run bind a single debounced resize listener
if (!initImagesOnPathScroll.resizeHandler) {
initImagesOnPathScroll.resizeHandler = debounce(() => {
initImagesOnPathScroll();
}, 200);
window.addEventListener('resize', initImagesOnPathScroll.resizeHandler);
}
return tl;
}
function debounce(fn, delay = 200) {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(fn, delay);
};
}
// Initialize Follow SVG Path on Scroll
document.addEventListener("DOMContentLoaded", () =>{
initImagesOnPathScroll();
});Guide
Overview
This effect combines GSAP's ScrollTrigger and MotionPathPlugin to animate a stack of items along any SVG path as the user scrolls. Each item fades in, unblurs, scales up, reveals its details, then fades out — all scrubbed directly to scroll position.
HTML Structure
Use data-motionpath="wrap" on the outer container (its height controls animation duration), data-motionpath="path" on the SVG <path> element defining the curve, data-motionpath="item" on each item to animate, and data-motionpath="item-details" on child elements that should animate separately.
ScrollTrigger Options
scrub: true syncs the timeline with scroll progress — combine with Lenis for extra smoothness. Adjust stagger (default 0.3) to control spacing between items along the path.
MotionPath Options
The path option targets the SVG path element. align snaps items to the path coordinates. alignOrigin: [0.5, 0.5] centers each item on the path. curviness: 2 smooths the motion between path points — increase for more arc, decrease for straighter movement.
Responsiveness
The SVG uses preserveAspectRatio="none" so the path scales with the viewport. The path container uses vmax and vh units for flexible sizing. Adjust max-height values per breakpoint to match your layout. On mobile, consider removing the animation entirely due to the performance cost of animating blur and complex transforms.
Resize Handling
The init function stores its timeline on itself and kills the old one on resize, preserving scroll progress. A debounced resize listener is attached only once to avoid duplicates.
CMS / Webflow
Replace motionpath-content-wrap, motionpath-content-list, and motionpath-content-item with your CMS List Wrap, CMS List, and CMS List Item respectively. For static lists, items stack with position: absolute — use a temporary overflow: scroll wrapper during editing so content stays accessible.