One of many hero designs we got here up with for Components 1 driver Lando Norris’s new web site had an fascinating problem: animating a component alongside a easy curved path between a number of mounted positions, one that may work throughout any system 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 totally different layouts, and provides us exact management over the trail’s form throughout growth.
On this tutorial, we’ll stroll by means of constructing a scroll-triggered curved path animation with a visible configurator instrument that permits you to dial within the excellent 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 tough half? Every place had totally different dimensions, the trail wanted to really feel pure and easy, and every little thing needed to recalculate completely when the browser window resized.
Static SVG paths wouldn’t minimize it. They’d break on totally different display sizes and couldn’t adapt to our responsive structure. We wanted curves that had been calculated dynamically primarily based on precise aspect positions.
Understanding Bezier Curves
Earlier than diving into code, let’s shortly cowl the inspiration: 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 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 complete:
- 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 aspect:
<div class="hero-section" data-section="hero">
<div class="place pos1" data-pos="1">Place 1</div>
<div class="place pos2" data-pos="2">Place 2</div>
<div class="place pos3" data-pos="3">Place 3</div>
<div class="animated-image" data-image="animated"></div>
</div>
The place parts function invisible anchors. We’ll measure their middle factors to calculate our path. In manufacturing, these would possible be hidden or eliminated totally, with CSS positioning defining the place the animated aspect ought to journey.
Calculating Dynamic Management Factors
First, we have to measure the place our anchor positions really 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.prime - rectSection.prime + r.peak / 2,
width: r.width,
peak: r.peak,
};
});
}
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 fascinating 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, retaining 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 section
The Math.min() constraints stop the management factors from extending too far on extraordinarily giant screens.
Constructing the SVG Path String
As soon as we’ve got 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,
peak: positions[0].peak,
});
const tl = gsap.timeline({
scrollTrigger: {
set off: part,
begin: 'prime prime',
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 aspect on the trail
Various enter: array-based paths
GSAP’s MotionPath plugin may construct paths immediately from level knowledge, relatively than a full SVG path string. You’ll be able to move an array of anchor and control-point coordinates and let GSAP generate the cubic Bezier internally.
You’ll be able to see a minimal demo exhibiting this method in motion right here: https://codepen.io/GreenSock/pen/raerLaK
In our case, we generate the SVG path explicitly so we will visualise and debug it within the configurator, however for less complicated setups this array-based syntax generally is a light-weight various.
Interpolating Dimension Alongside the Path
As our aspect travels alongside the trail, we would like it to easily transition from every place’s dimensions to the following. 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 peak = positions[0].peak +
(positions[1].peak - positions[0].peak) * normalizedProgress;
img.model.width = `${width}px`;
img.model.peak = `${peak}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 peak = positions[1].peak +
(positions[2].peak - positions[1].peak) * normalizedProgress;
img.model.width = `${width}px`;
img.model.peak = `${peak}px`;
}
}
We cut up the animation on the 50% mark (once we attain place 2), then normalise the progress for every section (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 fascinating for growth workflow. Auto-calculated management factors are an important start line, however each design is totally different. We have to fine-tune these curves, however adjusting multipliers in code and refreshing the browser will get tedious quick.
As an alternative, we constructed a visible configurator that lets us drag management factors and see the ends in real-time.
Creating the Debug Overlay
We create an SVG overlay that sits above our animated aspect:
const debugSvg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
debugSvg.model.place = 'absolute';
debugSvg.model.prime = 0;
debugSvg.model.left = 0;
debugSvg.model.width = '100%';
debugSvg.model.peak = '100%';
debugSvg.model.pointerEvents = 'none';
debugSvg.model.zIndex = 15;
part.appendChild(debugSvg);
Then we add visible parts:
// The trail itself (crimson 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 (crimson 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.model.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 a whole visible illustration of our Bezier curve construction, one thing you’d see in vector modifying software program like Illustrator or Figma.
Making Management Factors Draggable
The drag interplay is easy: monitor 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.incorporates('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.prime;
// 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);
If 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 parts
This provides instantaneous visible suggestions as you modify the curve.
Exporting Closing Values
When you’ve dialed within the excellent 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’ll be able to paste immediately 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 area of the scroll part) so the curve form adapts proportionally to the brand new structure.
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 price mentioning that GSAP features a plugin known as MotionPathHelper that gives comparable visible modifying capabilities for MotionPath animations. If you happen to’re working with extra advanced path eventualities or want options like path modifying with a number of curves, MotionPathHelper is price exploring.
For our use case, we wished 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 resolution. However when you’re searching for a ready-made path editor with broader capabilities, MotionPathHelper is a wonderful possibility.
Accessibility
For customers preferring lowered movement, we should always respect their system preferences. Whereas we will use JavaScript’s native matchMedia API, GSAP offers 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 totally
gsap.set(img, {
x: positions[2].x, // Soar to remaining place
y: positions[2].y,
width: positions[2].width,
peak: positions[2].peak,
});
return () => {
// Cleanup perform (optionally available)
};
});
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
// Run the total animation
buildAnimation();
return () => {
// Cleanup perform (optionally available)
};
});
GSAP’s matchMedia gives benefits over the native API: it mechanically manages cleanup when media queries change, integrates higher with GSAP’s animation lifecycle, and offers a constant API for all responsive behaviors. This instantly locations the aspect at its remaining place for customers who’ve indicated they like lowered movement, whereas working the total animation for everybody else.
*(Be aware: We didn’t implement this on the dwell Lando Norris web site 😬, however it’s positively a finest apply price following.)*
Manufacturing Workflow
Our growth workflow seems like this:
- Preliminary Setup: Place the anchor parts the place you need them utilizing CSS
- Auto-Calculate: Let the default management factors provide you with a beginning curve
- Advantageous-Tune: Open the configurator, drag management factors till the curve feels proper
- Export: Copy the ultimate management level values
- Deploy: Change the auto-calculated management factors along 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 immediately as an alternative of calculateDefaultControlPoints()
Wrapping Up
Constructing this scroll-triggered curved path animation taught us a priceless lesson about balancing automation with management. Auto-calculated management factors provide you with an important start line, however being able to visually refine them makes all of the distinction in attaining that excellent curve.
The mix 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 system 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 gadgets.


