One of many hero designs we got here up with for Method 1 driver Lando Norris’s new web site had an attention-grabbing problem: animating a component alongside a easy curved path between a number of mounted positions, one that may work throughout any gadget dimension. Whereas GSAP’s MotionPath plugin makes path-based animation easy, we would have liked one thing extra dynamic. We wanted a system that would recalculate its curves responsively, adapt to completely different layouts, and provides us exact management over the trail’s form throughout growth.
On this tutorial, we’ll stroll via constructing a scroll-triggered curved path animation with a visible configurator software that permits you to dial within the good curve by dragging management factors in real-time.
Instruments Used:
Paths & Management Factors demo ↗
The Design Problem
The idea was easy: as customers scroll, a component ought to journey easily alongside a curved path between three particular positions on the web page, altering dimension because it strikes. The difficult half? Every place had completely different dimensions, the trail wanted to really feel pure and easy, and all the things needed to recalculate completely when the browser window resized.
Static SVG paths wouldn’t lower it. They’d break on completely different display sizes and couldn’t adapt to our responsive format. We wanted curves that had been calculated dynamically primarily based on precise factor positions.
Understanding Bezier Curves
Earlier than diving into code, let’s rapidly cowl the muse: cubic Bezier curves. These curves are outlined by 4 factors:
- Begin level (anchor)
- First management level (CP1) – “pulls” the curve away from the beginning
- Second management level (CP2) – “pulls” the curve towards the tip
- Finish level (anchor)
In SVG path syntax, this seems to be like:
M x1,y1 C cpx1,cpy1 cpx2,cpy2 x2,y2
The place M strikes to the beginning level, and C attracts a cubic Bezier curve utilizing two management factors.
For our animation between three positions, we’d like two curve segments, which suggests 4 management factors whole:
- CP1 and CP2 for the primary curve (Place 1 → Place 2)
- CP3 and CP4 for the second curve (Place 2 → Place 3)
Setting Up the HTML Construction
Our markup is deliberately minimal. We outline three place markers and one animated factor:
Place 1
Place 2
Place 3
The place components function invisible anchors. We’ll measure their middle factors to calculate our path. In manufacturing, these would probably be hidden or eliminated solely, with CSS positioning defining the place the animated factor ought to journey.
Calculating Dynamic Management Factors
First, we have to measure the place our anchor positions truly are on the web page:
perform getPositions() {
const part = doc.querySelector('[data-section="hero"]');
const pos1 = doc.querySelector('[data-pos="1"]');
const pos2 = doc.querySelector('[data-pos="2"]');
const pos3 = doc.querySelector('[data-pos="3"]');
const rectSection = part.getBoundingClientRect();
return [pos1, pos2, pos3].map((el) => {
const r = el.getBoundingClientRect();
return {
x: r.left - rectSection.left + r.width / 2,
y: r.high - rectSection.high + r.top / 2,
width: r.width,
top: r.top,
};
});
}
This perform returns the middle level of every place relative to our scroll part, together with their dimensions. We’ll want these for dimension interpolation later.
Now for the attention-grabbing half: mechanically calculating management factors that create easy S-curves. Right here’s our method:
perform calculateDefaultControlPoints(positions) {
return [
// CP1: Extends from position 1 toward position 2
{
x: positions[0].x,
y: positions[0].y + (positions[1].y - positions[0].y) * 0.8,
},
// CP2: Approaches place 2 from above
{
x: positions[1].x,
y: positions[1].y - Math.min(800, (positions[1].y - positions[0].y) * 0.3),
},
// CP3: Extends from place 2 towards place 3
{
x: positions[1].x,
y: positions[1].y + Math.min(80, (positions[2].y - positions[1].y) * 0.3),
},
// CP4: Approaches place 3 from above
{
x: positions[1].x + (positions[2].x - positions[1].x) * 0.6,
y: positions[2].y - (positions[2].y - positions[1].y) * 0.2,
}
];
}
The magic is in these multipliers (0.8, 0.3, 0.6, 0.2). They management how “pulled” the curve is:
- CP1 extends 80% of the vertical distance from place 1, conserving it horizontally centered to create a downward arc
- CP2 sits above place 2, guaranteeing a easy vertical method
- CP3 and CP4 work equally for the second curve phase
The Math.min() constraints forestall the management factors from extending too far on extraordinarily giant screens.
Constructing the SVG Path String
As soon as we now have our positions and management factors, we assemble an SVG path:
perform buildPathString(positions, controlPoints) {
return `M${positions[0].x},${positions[0].y} ` +
`C${controlPoints[0].x},${controlPoints[0].y} ` +
`${controlPoints[1].x},${controlPoints[1].y} ` +
`${positions[1].x},${positions[1].y} ` +
`C${controlPoints[2].x},${controlPoints[2].y} ` +
`${controlPoints[3].x},${controlPoints[3].y} ` +
`${positions[2].x},${positions[2].y}`;
}
This creates a steady path with two cubic Bezier curves, forming our S-shape.
Animating with GSAP’s MotionPath
Now we hand this path to GSAP. The MotionPath plugin does the heavy lifting of calculating positions alongside our curve:
const pathString = buildPathString(positions, controlPoints);
gsap.set(img, {
x: positions[0].x,
y: positions[0].y,
width: positions[0].width,
top: positions[0].top,
});
const tl = gsap.timeline({
scrollTrigger: {
set off: part,
begin: 'high high',
finish: 'backside backside',
scrub: true,
invalidateOnRefresh: true,
},
});
tl.to(img, {
period: 1.5,
motionPath: {
path: pathString,
autoRotate: false,
},
ease: 'none',
onUpdate: perform () {
// Dimension interpolation logic right here
},
});
Key factors:
- scrub: true: Ties the animation progress on to scroll place
- invalidateOnRefresh: true: Ensures paths recalculate when the window resizes
- ease: ‘none’: Linear development provides us predictable scroll-to-position mapping
- transformOrigin: ‘50% 50%’: Facilities the factor on the trail
Various enter: array-based paths
GSAP’s MotionPath plugin also can construct paths straight from level information, fairly than a full SVG path string. You may move an array of anchor and control-point coordinates and let GSAP generate the cubic Bezier internally.
You may see a minimal demo displaying this method in motion right here: https://codepen.io/GreenSock/pen/raerLaK
In our case, we generate the SVG path explicitly so we are able to visualise and debug it within the configurator, however for easier setups this array-based syntax is usually a light-weight different.
Interpolating Dimension Alongside the Path
As our factor travels alongside the trail, we would like it to easily transition from every place’s dimensions to the subsequent. We deal with this within the onUpdate callback:
onUpdate: perform () {
const progress = this.progress();
// First half: interpolate between place 1 and place 2
if (progress <= 0.5) {
const normalizedProgress = progress * 2;
const width = positions[0].width +
(positions[1].width - positions[0].width) * normalizedProgress;
const top = positions[0].top +
(positions[1].top - positions[0].top) * normalizedProgress;
img.fashion.width = `${width}px`;
img.fashion.top = `${top}px`;
}
// Second half: interpolate between place 2 and place 3
else {
const normalizedProgress = (progress - 0.5) * 2;
const width = positions[1].width +
(positions[2].width - positions[1].width) * normalizedProgress;
const top = positions[1].top +
(positions[2].top - positions[1].top) * normalizedProgress;
img.fashion.width = `${width}px`;
img.fashion.top = `${top}px`;
}
}
We cut up the animation on the 50% mark (after we attain place 2), then normalise the progress for every phase (0-1 for every half), giving us easy dimension transitions that align with the trail.
Constructing the Visible Configurator
Right here’s the place issues get attention-grabbing for growth workflow. Auto-calculated management factors are a terrific place to begin, however each design is completely different. We have to fine-tune these curves, however adjusting multipliers in code and refreshing the browser will get tedious quick.
As a substitute, we constructed a visible configurator that lets us drag management factors and see the leads to real-time.
Creating the Debug Overlay
We create an SVG overlay that sits above our animated factor:
const debugSvg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
debugSvg.fashion.place = 'absolute';
debugSvg.fashion.high = 0;
debugSvg.fashion.left = 0;
debugSvg.fashion.width = '100%';
debugSvg.fashion.top = '100%';
debugSvg.fashion.pointerEvents = 'none';
debugSvg.fashion.zIndex = 15;
part.appendChild(debugSvg);
Then we add visible components:
// The trail itself (pink line)
const debugPath = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
debugPath.setAttribute('stroke', '#ff0040');
debugPath.setAttribute('stroke-width', '3');
debugPath.setAttribute('fill', 'none');
debugSvg.appendChild(debugPath);
// Anchor factors (pink circles at positions 1, 2, 3)
for (let i = 0; i < 3; i++) {
const circle = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '8');
circle.setAttribute('fill', '#ff0040');
debugSvg.appendChild(circle);
anchorPoints.push(circle);
}
// Management factors (inexperienced circles - these are draggable)
for (let i = 0; i < 4; i++) {
const circle = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '8');
circle.setAttribute('fill', '#00ff88');
circle.setAttribute('class', 'svg-control-point');
circle.fashion.pointerEvents = 'all'; // Allow interplay
circle.dataset.index = i;
debugSvg.appendChild(circle);
controlPointElements.push(circle);
}
// Deal with strains (dashed strains connecting controls to anchors)
for (let i = 0; i < 4; i++) {
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('stroke', '#00ff88');
line.setAttribute('stroke-dasharray', '4,4');
debugSvg.appendChild(line);
handleLines.push(line);
}
This provides us an entire visible illustration of our Bezier curve construction, one thing you’d see in vector enhancing software program like Illustrator or Figma.
Making Management Factors Draggable
The drag interplay is simple: observe mouse/contact place and replace the management level coordinates:
let isDragging = false;
let currentDragIndex = -1;
perform startDrag(e) {
const goal = e.goal;
if (goal.classList.comprises('svg-control-point')) {
isDragging = true;
currentDragIndex = parseInt(goal.dataset.index);
goal.classList.add('dragging');
e.preventDefault();
}
}
perform drag(e) {
if (!isDragging || currentDragIndex === -1) return;
const rectSection = part.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
const newX = clientX - rectSection.left;
const newY = clientY - rectSection.high;
// Replace the management level
currentControlPoints[currentDragIndex] = { x: newX, y: newY };
// Rebuild the visualization and animation
updateVisualization();
buildAnimation();
}
perform endDrag() {
if (isDragging) {
const circles = debugSvg.querySelectorAll('.svg-control-point');
circles.forEach(c => c.classList.take away('dragging'));
isDragging = false;
currentDragIndex = -1;
}
}
debugSvg.addEventListener('mousedown', startDrag);
doc.addEventListener('mousemove', drag);
doc.addEventListener('mouseup', endDrag);
Whenever you drag a management level, we:
- Replace its place within the currentControlPoints array
- Rebuild the trail string
- Kill and recreate the GSAP animation with the brand new path
- Replace all visible components
This provides on the spot visible suggestions as you modify the curve.
Exporting Closing Values
When you’ve dialed within the good curve, you’ll need these management level values for manufacturing:
perform copyValues() {
const valuesText = currentControlPoints.map((cp, i) =>
`const controlPoint${i + 1} = {n x: ${Math.spherical(cp.x)},n y: ${Math.spherical(cp.y)}n};`
).be a part of('nn');
navigator.clipboard.writeText(valuesText);
}
This codecs the coordinates as JavaScript constants you may paste straight into your manufacturing code.
Dealing with Responsiveness
Right here’s the place our dynamic method pays off. When the window resizes:
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
positions = getPositions();
updateVisualization();
buildAnimation();
}, 200);
});
We recalculate positions, rebuild the trail, and recreate the animation. The management level coordinates stay the identical (they’re already within the coordinate house of the scroll part) so the curve form adapts proportionally to the brand new format.
That is essential for responsive design. The identical curve construction works whether or not you’re on a cellphone, pill, or ultra-wide monitor.
A Be aware on GSAP’s MotionPathHelper
It’s value mentioning that GSAP features a plugin referred to as MotionPathHelper that gives related visible enhancing capabilities for MotionPath animations. Should you’re working with extra complicated path eventualities or want options like path enhancing with a number of curves, MotionPathHelper is value exploring.
For our use case, we needed tight integration with our scroll-triggered animation and a workflow particularly tailor-made to our three-position setup, which is why we constructed a customized answer. However for those who’re on the lookout for a ready-made path editor with broader capabilities, MotionPathHelper is a wonderful possibility.
Accessibility
For customers preferring diminished movement, we must always respect their system preferences. Whereas we are able to use JavaScript’s native matchMedia API, GSAP supplies its personal matchMedia utility that integrates seamlessly with its animation system:
// Utilizing GSAP's matchMedia
gsap.matchMedia().add("(prefers-reduced-motion: scale back)", () => {
// Skip the curved path animation solely
gsap.set(img, {
x: positions[2].x, // Leap to last place
y: positions[2].y,
width: positions[2].width,
top: positions[2].top,
});
return () => {
// Cleanup perform (non-obligatory)
};
});
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
// Run the complete animation
buildAnimation();
return () => {
// Cleanup perform (non-obligatory)
};
});
GSAP’s matchMedia presents benefits over the native API: it mechanically manages cleanup when media queries change, integrates higher with GSAP’s animation lifecycle, and supplies a constant API for all responsive behaviors. This instantly locations the factor at its last place for customers who’ve indicated they like diminished movement, whereas working the complete animation for everybody else.
*(Be aware: We didn’t implement this on the reside Lando Norris website 😬, however it’s undoubtedly a finest follow value following.)*
Manufacturing Workflow
Our growth workflow seems to be like this:
- Preliminary Setup: Place the anchor components the place you need them utilizing CSS
- Auto-Calculate: Let the default management factors offer you a beginning curve
- Advantageous-Tune: Open the configurator, drag management factors till the curve feels proper
- Export: Copy the ultimate management level values
- Deploy: Substitute the auto-calculated management factors together with your customized values
- Clear Up: Take away the configurator code and debug visualisation for manufacturing
In manufacturing, you’d sometimes hard-code the management factors and take away all of the configurator UI:
const controlPoints = [
{ x: 150, y: 450 },
{ x: 800, y: 800 },
{ x: 850, y: 1200 },
{ x: 650, y: 1400 }
];
// Use these straight as a substitute of calculateDefaultControlPoints()
Wrapping Up
Constructing this scroll-triggered curved path animation taught us a beneficial lesson about balancing automation with management. Auto-calculated management factors offer you a terrific place to begin, however being able to visually refine them makes all of the distinction in attaining that good curve.
The mixture of GSAP’s highly effective MotionPath plugin, ScrollTrigger for scroll-syncing, and a customized visible configurator gave us precisely what we would have liked: a responsive animation system that appears nice on any gadget and a growth workflow that doesn’t contain guessing at coordinates.
For the Lando Norris web site, this method allowed us to create a hero animation that feels easy, intentional, and completely tailor-made to the design whereas staying absolutely responsive throughout units.








