One of the hero designs we came up with for Formula 1 driver Lando Norrisâs new website had an interesting challenge: animating an element along a smooth curved path between multiple fixed positions, one that would work across any device size. While GSAPâs MotionPath plugin makes path-based animation straightforward, we needed something more dynamic. We needed a system that could recalculate its curves responsively, adapt to different layouts, and give us precise control over the pathâs shape during development.
In this tutorial, weâll walk through building a scroll-triggered curved path animation with a visual configurator tool that lets you dial in the perfect curve by dragging control points in real-time.
Tools Used:
Paths & Control Points demo â
The Design Challenge
The concept was simple: as users scroll, an element should travel smoothly along a curved path between three specific positions on the page, changing size as it moves. The tricky part? Each position had different dimensions, the path needed to feel natural and smooth, and everything had to recalculate perfectly when the browser window resized.
Static SVG paths wouldnât cut it. Theyâd break on different screen sizes and couldnât adapt to our responsive layout. We needed curves that were calculated dynamically based on actual element positions.
Understanding Bezier Curves
Before diving into code, letâs quickly cover the foundation: cubic Bezier curves. These curves are defined by four points:
- Start point (anchor)
- First control point (CP1) â âpullsâ the curve away from the start
- Second control point (CP2) â âpullsâ the curve toward the end
- End point (anchor)
In SVG path syntax, this looks like:
M x1,y1 C cpx1,cpy1 cpx2,cpy2 x2,y2
Where M moves to the start point, and C draws a cubic Bezier curve using two control points.
For our animation between three positions, we need two curve segments, which means four control points total:
- CP1 and CP2 for the first curve (Position 1 â Position 2)
- CP3 and CP4 for the second curve (Position 2 â Position 3)
Setting Up the HTML Structure
Our markup is intentionally minimal. We define three position markers and one animated element:
Position 1
Position 2
Position 3
The position elements serve as invisible anchors. Weâll measure their center points to calculate our path. In production, these would likely be hidden or removed entirely, with CSS positioning defining where the animated element should travel.
Calculating Dynamic Control Points
First, we need to measure where our anchor positions actually are on the page:
function getPositions() {
const section = document.querySelector('[data-section="hero"]');
const pos1 = document.querySelector('[data-pos="1"]');
const pos2 = document.querySelector('[data-pos="2"]');
const pos3 = document.querySelector('[data-pos="3"]');
const rectSection = section.getBoundingClientRect();
return [pos1, pos2, pos3].map((el) => {
const r = el.getBoundingClientRect();
return {
x: r.left - rectSection.left + r.width / 2,
y: r.top - rectSection.top + r.height / 2,
width: r.width,
height: r.height,
};
});
}
This function returns the center point of each position relative to our scroll section, along with their dimensions. Weâll need these for size interpolation later.
Now for the interesting part: automatically calculating control points that create smooth S-curves. Hereâs our approach:
function 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 position 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 position 2 toward position 3
{
x: positions[1].x,
y: positions[1].y + Math.min(80, (positions[2].y - positions[1].y) * 0.3),
},
// CP4: Approaches position 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 those multipliers (0.8, 0.3, 0.6, 0.2). They control how âpulledâ the curve is:
- CP1 extends 80% of the vertical distance from position 1, keeping it horizontally centered to create a downward arc
- CP2 sits above position 2, ensuring a smooth vertical approach
- CP3 and CP4 work similarly for the second curve segment
The Math.min() constraints prevent the control points from extending too far on extremely large screens.
Building the SVG Path String
Once we have our positions and control points, we construct an SVG path:
function 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 continuous 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 along our curve:
const pathString = buildPathString(positions, controlPoints);
gsap.set(img, {
x: positions[0].x,
y: positions[0].y,
width: positions[0].width,
height: positions[0].height,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: section,
start: 'top top',
end: 'bottom bottom',
scrub: true,
invalidateOnRefresh: true,
},
});
tl.to(img, {
duration: 1.5,
motionPath: {
path: pathString,
autoRotate: false,
},
ease: 'none',
onUpdate: function () {
// Size interpolation logic here
},
});
Key points:
- scrub: true: Ties the animation progress directly to scroll position
- invalidateOnRefresh: true: Ensures paths recalculate when the window resizes
- ease: ânoneâ: Linear progression gives us predictable scroll-to-position mapping
- transformOrigin: â50% 50%â: Centers the element on the path
Alternative input: array-based paths
GSAPâs MotionPath plugin can also build paths directly from point data, rather than a full SVG path string. You can pass an array of anchor and control-point coordinates and let GSAP generate the cubic Bezier internally.
You can see a minimal demo showing this approach in action here: https://codepen.io/GreenSock/pen/raerLaK
In our case, we generate the SVG path explicitly so we can visualise and debug it in the configurator, but for simpler setups this array-based syntax can be a lightweight alternative.
Interpolating Size Along the Path
As our element travels along the path, we want it to smoothly transition from each positionâs dimensions to the next. We handle this in the onUpdate callback:
onUpdate: function () {
const progress = this.progress();
// First half: interpolate between position 1 and position 2
if (progress <= 0.5) {
const normalizedProgress = progress * 2;
const width = positions[0].width +
(positions[1].width - positions[0].width) * normalizedProgress;
const height = positions[0].height +
(positions[1].height - positions[0].height) * normalizedProgress;
img.style.width = `${width}px`;
img.style.height = `${height}px`;
}
// Second half: interpolate between position 2 and position 3
else {
const normalizedProgress = (progress - 0.5) * 2;
const width = positions[1].width +
(positions[2].width - positions[1].width) * normalizedProgress;
const height = positions[1].height +
(positions[2].height - positions[1].height) * normalizedProgress;
img.style.width = `${width}px`;
img.style.height = `${height}px`;
}
}
We split the animation at the 50% mark (when we reach position 2), then normalise the progress for each segment (0-1 for each half), giving us smooth size transitions that align with the path.
Building the Visual Configurator
Hereâs where things get interesting for development workflow. Auto-calculated control points are a great starting point, but every design is different. We need to fine-tune these curves, but adjusting multipliers in code and refreshing the browser gets tedious fast.
Instead, we built a visual configurator that lets us drag control points and see the results in real-time.
Creating the Debug Overlay
We create an SVG overlay that sits above our animated element:
const debugSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
debugSvg.style.position = 'absolute';
debugSvg.style.top = 0;
debugSvg.style.left = 0;
debugSvg.style.width = '100%';
debugSvg.style.height = '100%';
debugSvg.style.pointerEvents = 'none';
debugSvg.style.zIndex = 15;
section.appendChild(debugSvg);
Then we add visual elements:
// The path itself (red line)
const debugPath = document.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 points (red circles at positions 1, 2, 3)
for (let i = 0; i < 3; i++) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '8');
circle.setAttribute('fill', '#ff0040');
debugSvg.appendChild(circle);
anchorPoints.push(circle);
}
// Control points (green circles - these are draggable)
for (let i = 0; i < 4; i++) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '8');
circle.setAttribute('fill', '#00ff88');
circle.setAttribute('class', 'svg-control-point');
circle.style.pointerEvents = 'all'; // Enable interaction
circle.dataset.index = i;
debugSvg.appendChild(circle);
controlPointElements.push(circle);
}
// Handle lines (dashed lines connecting controls to anchors)
for (let i = 0; i < 4; i++) {
const line = document.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 gives us a complete visual representation of our Bezier curve structure, something youâd see in vector editing software like Illustrator or Figma.
Making Control Points Draggable
The drag interaction is straightforward: track mouse/touch position and update the control point coordinates:
let isDragging = false;
let currentDragIndex = -1;
function startDrag(e) {
const target = e.target;
if (target.classList.contains('svg-control-point')) {
isDragging = true;
currentDragIndex = parseInt(target.dataset.index);
target.classList.add('dragging');
e.preventDefault();
}
}
function drag(e) {
if (!isDragging || currentDragIndex === -1) return;
const rectSection = section.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.top;
// Update the control point
currentControlPoints[currentDragIndex] = { x: newX, y: newY };
// Rebuild the visualization and animation
updateVisualization();
buildAnimation();
}
function endDrag() {
if (isDragging) {
const circles = debugSvg.querySelectorAll('.svg-control-point');
circles.forEach(c => c.classList.remove('dragging'));
isDragging = false;
currentDragIndex = -1;
}
}
debugSvg.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', endDrag);
When you drag a control point, we:
- Update its position in the currentControlPoints array
- Rebuild the path string
- Kill and recreate the GSAP animation with the new path
- Update all visual elements
This gives instant visual feedback as you adjust the curve.
Exporting Final Values
Once youâve dialed in the perfect curve, youâll want those control point values for production:
function copyValues() {
const valuesText = currentControlPoints.map((cp, i) =>
`const controlPoint${i + 1} = {n x: ${Math.round(cp.x)},n y: ${Math.round(cp.y)}n};`
).join('nn');
navigator.clipboard.writeText(valuesText);
}
This formats the coordinates as JavaScript constants you can paste directly into your production code.
Handling Responsiveness
Hereâs where our dynamic approach 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 path, and recreate the animation. The control point coordinates remain the same (theyâre already in the coordinate space of the scroll section) so the curve shape adapts proportionally to the new layout.
This is crucial for responsive design. The same curve structure works whether youâre on a phone, tablet, or ultra-wide monitor.
A Note on GSAPâs MotionPathHelper
Itâs worth mentioning that GSAP includes a plugin called MotionPathHelper that provides similar visual editing capabilities for MotionPath animations. If youâre working with more complex path scenarios or need features like path editing with multiple curves, MotionPathHelper is worth exploring.
For our use case, we wanted tight integration with our scroll-triggered animation and a workflow specifically tailored to our three-position setup, which is why we built a custom solution. But if youâre looking for a ready-made path editor with broader capabilities, MotionPathHelper is an excellent option.
Accessibility
For users who prefer reduced motion, we should respect their system preferences. While we can use JavaScriptâs native matchMedia API, GSAP provides its own matchMedia utility that integrates seamlessly with its animation system:
// Using GSAP's matchMedia
gsap.matchMedia().add("(prefers-reduced-motion: reduce)", () => {
// Skip the curved path animation entirely
gsap.set(img, {
x: positions[2].x, // Jump to final position
y: positions[2].y,
width: positions[2].width,
height: positions[2].height,
});
return () => {
// Cleanup function (optional)
};
});
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
// Run the full animation
buildAnimation();
return () => {
// Cleanup function (optional)
};
});
GSAPâs matchMedia offers advantages over the native API: it automatically manages cleanup when media queries change, integrates better with GSAPâs animation lifecycle, and provides a consistent API for all responsive behaviors. This immediately places the element at its final position for users whoâve indicated they prefer reduced motion, while running the full animation for everyone else.
*(Note: We didnât implement this on the live Lando Norris site đŹ, but itâs definitely a best practice worth following.)*
Production Workflow
Our development workflow looks like this:
- Initial Setup: Position the anchor elements where you want them using CSS
- Auto-Calculate: Let the default control points give you a starting curve
- Fine-Tune: Open the configurator, drag control points until the curve feels right
- Export: Copy the final control point values
- Deploy: Replace the auto-calculated control points with your custom values
- Clean Up: Remove the configurator code and debug visualisation for production
In production, youâd typically hard-code the control points and remove all the configurator UI:
const controlPoints = [
{ x: 150, y: 450 },
{ x: 800, y: 800 },
{ x: 850, y: 1200 },
{ x: 650, y: 1400 }
];
// Use these directly instead of calculateDefaultControlPoints()
Wrapping Up
Building this scroll-triggered curved path animation taught us a valuable lesson about balancing automation with control. Auto-calculated control points give you a great starting point, but having the ability to visually refine them makes all the difference in achieving that perfect curve.
The combination of GSAPâs powerful MotionPath plugin, ScrollTrigger for scroll-syncing, and a custom visual configurator gave us exactly what we needed: a responsive animation system that looks great on any device and a development workflow that doesnât involve guessing at coordinates.
For the Lando Norris website, this approach allowed us to create a hero animation that feels smooth, intentional, and perfectly tailored to the design while staying fully responsive across devices.
