Build responsive, scroll-triggered curved path animations with GSAP | Codrops

Build responsive, scroll-triggered curved path animations with GSAP | Codrops

11 minutes, 25 seconds Read

One of the hero designs we came up with for Formula 1 driver Lando Norris’ new website had an interesting challenge: animating an element along a smoothly curved path between multiple fixed positions, an element that would work on any device size. While GSAP’s MotionPath plugin makes path-based animation simple, we needed something more dynamic. We needed a system that could recalculate the curves responsively, adapt to different layouts, and give us precise control over the shape of the path during development.

In this tutorial, we’ll walk through building a scroll-activated curved path animation with a visual configuration tool which allows you to determine the perfect curve by dragging control points in real time.

Tools used:

Demo Paths and checkpoints ↗


Free GSAP 3 Express Course

Learn modern web animation with GSAP 3 with 34 hands-on video lessons and hands-on projects — perfect for all skill levels.

Check it out

The design challenge

The concept was simple: as users scroll, an element must move 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 had to feel natural and fluid, and everything had to recompute perfectly when the browser window was resized.

Static SVG paths are not enough. They broke on different screen sizes and couldn’t adapt to our responsive layout. We needed curves that were dynamically calculated based on actual element positions.

Understanding Bezier Curves

Before we dive into the code, let’s take a closer look at the basics: cubic Bezier curves. These curves are defined by four points:

  1. Starting point (anchor)
  2. First checkpoint (CP1) – “pulls” the curve away from the beginning
  3. Second checkpoint (CP2) – “pulls” the curve towards the end
  4. Endpoint (anchor)

In SVG path syntax this looks like this:

M x1,y1 C cpx1,cpy1 cpx2,cpy2 x2,y2

Where M moves to the starting 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 a total of four control points:

  • 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 measure their centers to calculate our path. In production these would likely be hidden or removed entirely, with CSS positioning defining where the animated element should go.

Calculate dynamic control points

First we need to measure where our anchor positions are actually 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 later for size interpolation.

Now the interesting part: automatically calculating control points that create smooth S-curves. This is 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 determine how “drawn” the curve is:

  • CP1 extends 80% of the vertical distance from position 1, staying horizontally centered to create a downward arc
  • CP2 sits above position 2 and provides a smooth vertical approach
  • CP3 and CP4 work in the same way for the second curve segment

The Math.min() limitations prevent the control points from stretching too far on extremely large screens.

Building the SVG path sequence

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.

Animate with GSAP’s MotionPath

Now we hand this path over to GSAP. The MotionPath plugin does the heavy lifting in 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: Links the animation progress directly to the scroll position
  • invalidateOnRefresh: true: Causes paths to be recalculated when the window is resized
  • convenience: ‘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, instead of a full SVG path string. You can pass a series of anchor and control point coordinates and have 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 visualize and debug it in the configurator, but for simpler settings this array-based syntax can be a lightweight alternative.

Interpolate size along the path

As our element travels along the path, we want it to transition smoothly from the dimensions of each position to the next. We will arrange this in the onUpdate call back:

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) and then normalize the progress for each segment (0-1 for each half), giving us smooth size transitions in line with the path.

Building the visual configurator

This is where things get interesting for the development workflow. Automatically calculated control points are a good starting point, but every design is different. We need to refine these curves, but adjusting multipliers in the code and refreshing the browser quickly becomes tedious.

Instead, we built a visual configurator that allows us to drag control points and see the results in real time.

Create the debug overlay

We’ll 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 full visual representation of our Bezier curve structure, something you would see in vector editing software like Illustrator or Figma.

Make control points draggable

The drag interaction is simple: follow the 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 checkpoint, we do the following:

  1. Update its position in the currentControlPoints array
  2. Rebuild the path sequence
  3. Kill the GSAP animation and recreate it with the new path
  4. Update all visual elements

This provides instant visual feedback as you adjust the curve.

Export final values

Once you’ve chosen the perfect curve, you’ll want these checkpoint 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('\n\n');
  
  navigator.clipboard.writeText(valuesText);
}

This formats the coordinates as JavaScript constants that you can paste directly into your production code.

Dealing with responsiveness

This is where our dynamic approach pays off. When the window is resized:

let resizeTimeout;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimeout);
  resizeTimeout = setTimeout(() => {
    positions = getPositions();
    updateVisualization();
    buildAnimation();
  }, 200);
});

We recalculate the positions, rebuild the path and recreate the animation. The coordinates of the control point remain the same (they are already in the coordinate space of the sliding section), so the shape of the curve adapts proportionally to the new layout.

This is crucial for responsive design. The same curve structure works whether you’re using a phone, tablet, or ultrawide monitor.

A note about GSAP’s MotionPathHelper

It’s worth mentioning that GSAP includes a plugin called MotionPathHelper which provides similar visual editing capabilities for MotionPath animations. If you work with more complex path scenarios or need features like multi-curve path editing, 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. That’s why we built a custom solution. But if you’re looking for an out-of-the-box path editor with broader capabilities, MotionPathHelper is an excellent option.

Accessibility

For users who prefer reduced motion, we must respect their system preferences. Although we are the native matchMedia API, GSAP provides its own API 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)
  };
});

GSAPs 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 behavior. This will immediately place the element in its final position for users who have indicated they prefer reduced motion, while running the full animation for everyone else.

*(Note: We haven’t implemented this on the live Lando Norris site 😬, but it’s certainly a best practice worth following.)*

Production workflow

Our development workflow looks like this:

  1. Initial installation: Place the anchor elements where you want them using CSS
  2. Calculate automatically: Let the standard control points give you a starting curve
  3. Fine tuning: Open the configurator, drag control points until the curve feels right
  4. Export: Copy the final control point values
  5. Apply: Replace the automatically calculated control points with your custom values
  6. Clean up: Remove the configurator code and debug the visualization for production

In production you would normally hardcode the checkpoints and remove the entire configurator user interface:

const controlPoints = [
  { x: 150, y: 450 },
  { x: 800, y: 800 },
  { x: 850, y: 1200 },
  { x: 650, y: 1400 }
];

// Use these directly instead of calculateDefaultControlPoints()

Packing

Building this scroll-activated curved path animation taught us a valuable lesson about balancing automation and control. Automatically calculated control points give you a good starting point, but 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 sync, 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 require you to guess 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 remaining fully responsive across all devices.

#Build #responsive #scrolltriggered #curved #path #animations #GSAP #Codrops

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *