Liquid SVG Paths with GSAP

A few weeks ago, I went on a GSAP forum adventure. On discovering this excellent thread, I became a bit obsessed. I had to find a way of recreating this kind of liquid effect myself. Well, after much trial and error, I discovered something rather cool.

With some Bézier curves, a touch of maths, and a single SVG <path>, one can get rather close to wobbly perfection. Let’s take a look at how.

To make the most out of this tutorial, you should be comfortable writing HTML, CSS, and JavaScript. Familiarity with SVG is also going to be beneficial.


Before we dive in and start learning, let’s check out a few demos of the liquid <path> effect in action.

First, here’s a very wobbly button. This is the example we will focus on in this tutorial:

Next, a liquid image mask:

Finally, a happy little jelly character:

Getting started

In this tutorial, we are going to dive deep into exactly how this effect works. To keep us focused on the wobbly magic, rather than the UI itself, I have set up a CodePen you can fork to get started. Here it is:

This CodePen includes all the HTML, CSS, and JavaScript imports you need for this tutorial. See the simple button element in the middle of the page? This is what we will be liquifying.

An animated overview

Before we write any code, I’d like to show you a little animated example of how this effect works. If you are a visual learner like me, this will be a helpful reference as we start coding.

To break this down into high-level steps:

  1. Take a regular SVG <path>, and split it onto equidistant points
  2. Draw a smooth curve through each point, and continue to do so roughly 60 times per second
  3. When the mouse gets within a certain range of a point, “push” it away from the mouse position
  4. When the mouse stops moving or moves out of range, reset the point to it’s original position
  5. Bask in the wobbly glory and/or make “wibble wobble” sounds with your voice (not required, just for fun)

Let’s code!

Alright, folks, now that we have a rough idea of how this effect works — let’s get wobbling. That button’s looking awfully solid.

Throughout this article, we will be referencing “points” quite a bit. A point, in our case, is a location within a 2D space. For us, this space is an SVG viewBox.

Storing the points

First things first, let’s store a reference to the <path> element we are going to liquify:

const buttonPath = document.getElementById('btn-path');

Next, let’s add a createLiquidPath function. This function will take 2 arguments — path (the target element) and options:

function createLiquidPath(path, options) {}

Lovely. That’s all looking good. Next, we need to split the <path> into equidistant { x, y } points.

From here, add all new code to the createLiquidPath function. I’ll let you know when we need to pop up back up into the global scope!

const svgPoints = pointsInPath(path, options.detail);
const originPoints ={ x, y }) => ({ x, y }));
const liquidPoints ={ x, y }) => ({ x, y }));

The originPoints array stores the original point positions. These will never change. The liquidPoints array stores the points we will be pushing around a little later. These will change position rather a lot.

Storing the mouse position

Next, we should create a mousePos variable, and a transformCoords function. This function will map the user’s mouse position (relative to the screen) to the viewBox of our SVG. The mousePos variable will store the mapped/translated coordinates.

const mousePos = { x: 0, y: 0 };
const transformCoords = createCoordsTransformer(path.closest('svg'));

To create the transformCoords function, we are using createCoordsTransformer from my generative-utils repo. This function takes one argument, an SVG element, and returns a new function. This new function will take care of all the coordinate mapping magic mentioned above.

Here’s a CodePen demonstrating what’s happening here:

Limiting each point’s movement

Next up, we should set up some constraints for how far each of our points can move away from its origin. We want our points to wobble around, but we don’t want them going too far from home.

const pointDistance = Math.hypot(
  originPoints[0].x - originPoints[1].x,
  originPoints[0].y - originPoints[1].y

const maxDist = {
  x: options.axis.includes('x') ? pointDistance / 2 : 0,
  y: options.axis.includes('y') ? pointDistance / 2 : 0,

This maxDist object has two values, x and y. These values define how far a point can move on its x/y axis. You may notice some sneaky ternary action here, too.

When we call our function a little later, we will pass in an axis array. What we are saying here is “only allow the points to move on x/y axis, if passed in the configuration”.

We set the greatest distance a point can move, to half the distance between two of our equidistant points. This is arbitrary but seems to work well for most cases. As always, feel free to experiment.

Updating the path data

So far, we have…

  1. Split our <path> into equidistant SVG points
  2. Converted these SVG points into regular JavaScript objects, with an x and a y value
  3. Set up a coordinate transformer function and mouse position point. We will be using these to add interactivity shortly!
  4. Defined some sensible constraints for each point’s movement

This is all very good, but we haven’t actually modified our <path> yet. To do so, let’s add the following code to our magical liquifying function:

gsap.ticker.add(() => {
  gsap.set(path, {
    attr: {
      d: spline(liquidPoints, options.tension, options.close),

GSAP’s ticker.add receives a function. This function will run over and over again, somewhere around 60 times per second. For this tutorial, you can think of it as a traditional animation loop. Within this loop, we use gsap.set to update the data value of our <path> element.

The beauty of gsap.set is that it will only update our <path> element if the path data has changed. This makes it far more efficient than using element.setAttribute('d', ...). If we used element.setAttribute, the DOM would update constantly, whether it needed to or not.

It’s worth noting that the gsap.set function does not apply any easing/motion, but that’s perfect for us. We don’t need it here. Why? All the wobbly motion comes from each point moving around.

Rendering the path

To generate the new path data, we are using the spline function from my generative-utils repo. I use this function a lot. In short, it allows you to draw a smooth curve through any number of { x, y } points. You can read more about my spline function here.

In our case, we are using spline to draw a smooth curve through our liquidPoints array. We pass a couple of extra arguments here, too — tension, and close. The former defines how smooth the curve should be, the latter tells the spline function to return a closed path.

Call the function

First things first, let’s check our user’s motion preferences. We can do this using the prefers-reduced-motion media query.

If our user is OK with motion, we call the function. If they aren’t, we do nothing — the button will remain the same. Always respect your user’s accessibility preferences, folks. Especially when building jelly-like UI elements…

It’s time to move out of our createLiquidPath function for a moment.

const prefersReducedMotionQuery = window.matchMedia(
  '(prefers-reduced-motion: reduce)'

if (prefersReducedMotionQuery && !prefersReducedMotionQuery.matches) {
  createLiquidPath(buttonPath, {
    detail: 32,
    tension: 1,
    close: true,
    range: {
      x: 12,
      y: 40,
    axis: ['y'],

To break down the options passed here:

  • detail is how many points the original <path> should be split into
  • tension (0, 1) is how smooth the curve should be
  • close tells the spline function if our new path data should be a closed shape
  • range we haven’t used just yet, stay tuned!
  • axis defines which axis should our points move on. In this case, we only want to move on the y axis

Awesome! Right now, calling createLiquidPath won’t do much. We haven’t set up any of our point-wobbling-magic yet. If you open up dev-tools and inspect the <path> element, though, you will see its d attribute is a little more complex. This is spline converting the points into a smooth series or Bézier curves.

Updating the mouse position

Our first step towards interactivity is to add a mousemove listener. Let’s add the following to our createLiquidPath function:

window.addEventListener('mousemove', (e) => {
  const { x, y } = transformCoords(e);

  mousePos.x = x;
  mousePos.y = y;

In this snippet, we are:

  • Listening for mousemove events
  • Mapping the mouse position to our SVG’s viewBox
  • Updating our mousePos point with transformed x and y values

Pushing around the points

Ok, now that mousePos is updating on mousemove, we can start pushing around our points. This is where things get interesting.

First, let’s break down the pushing process for each point into a series of steps:

  1. Check how far the mouse position is from the point’s origin
  2. If the x and y distances between the point’s origin and the mouse position are less than the x and y values assigned to options.range
  3. Calculate the difference between the point’s origin and the current mouse position. Store this as a new variable
  4. Create another variable target. This is the point’s origin, minus the difference we defined in step 3
  5. Clamp the target point’s x and y values to the maxDist properties we defined earlier. This will prevent our points from moving too far from their origin

Here’s a CodePen demonstrating the above steps on a single point. Move your mouse within the green circle to push the point around. In this case, the green circle represents both maxDist and range. I have some easing applied in this demo to make things a little easier to see.

Let’s pop the code we need to achieve the above steps after the mousePos updates we added a moment ago:

liquidPoints.forEach((point, index) => {
  const pointOrigin = originPoints[index];
  const distX = Math.abs(pointOrigin.x - mousePos.x);
  const distY = Math.abs(pointOrigin.y - mousePos.y);

  if (distX <= options.range.x && distY <= options.range.y) {
    const difference = {
      x: pointOrigin.x - mousePos.x,
      y: pointOrigin.y - mousePos.y,

    const target = {
      x: pointOrigin.x + difference.x,
      y: pointOrigin.y + difference.y,

    const x = gsap.utils.clamp(
      pointOrigin.x - maxDist.x,
      pointOrigin.x + maxDist.x,

    const y = gsap.utils.clamp(
      pointOrigin.y - maxDist.y,
      pointOrigin.y + maxDist.y,

Phew! That’s a lot. If you are feeling a little mathed-out, that’s cool. This stuff takes a while to learn for everyone. Even if you kind of understand what’s happening here, that’s fine! I often use code for a long time before I completely understand what it does.

Animating the points

Now that we know where each point should be pushed, we can animate it’s x and y values using This is one of the super cool things about GSAP, it can animate/tween anything. In our case, we are modifying the x and y values of point objects in our liquidPoints array.

Pop this code at the bottom of the distance checking if statement we added earlier:, {
  x: x,
  y: y,
  ease: 'sine',
  overwrite: true,
  duration: 0.175,
  onComplete() {, {
      x: pointOrigin.x,
      y: pointOrigin.y,
      ease: 'elastic.out(1, 0.3)',
      duration: 1.25,

What we are saying here is:

  • Over a period of 175ms…
  • Change the point’s x and y values to that of the target point’s
  • Apply a “sine” easing to the change/interpolation. Feel free to swap this! GSAP has an excellent page documenting its easing functions here
  • Once the update is complete, and the point’s x and y values match that of the target, “spring” the point back to its origin. I am using elastic easing here, but again, feel free to change it. Different easing values will give you a completely different feel

I’m using the GSAP option overwrite here to ensure that the “push” always overrides the “spring back”. If you set this to false, the animation can look a little jittery.

Now, if you move your mouse over near the button, it should begin to wobble around. If it doesn’t, don’t worry. Here’s a link to a completed code example.