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.
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.
Examples
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:
- Take a regular SVG
<path>
, and split it onto equidistant points - Draw a smooth curve through each point, and continue to do so roughly 60 times per second
- When the mouse gets within a certain range of a point, “push” it away from the mouse position
- When the mouse stops moving or moves out of range, reset the point to it’s original position
- 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 = svgPoints.map(({ x, y }) => ({ x, y }));
const liquidPoints = svgPoints.map(({ 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…
- Split our
<path>
into equidistant SVG points - Converted these SVG points into regular JavaScript objects, with an
x
and ay
value - Set up a coordinate transformer function and mouse position point. We will be using these to add interactivity shortly!
- 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 intotension
(0, 1) is how smooth the curve should beclose
tells the spline function if our new path data should be a closed shaperange
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 transformedx
andy
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:
- Check how far the mouse position is from the point’s origin
- If the
x
andy
distances between the point’s origin and the mouse position are less than thex
andy
values assigned tooptions.range
… - Calculate the difference between the point’s origin and the current mouse position. Store this as a new variable
- Create another variable
target
. This is the point’s origin, minus thedifference
we defined in step 3 - Clamp the
target
point’sx
andy
values to themaxDist
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,
target.x
);
const y = gsap.utils.clamp(
pointOrigin.y - maxDist.y,
pointOrigin.y + maxDist.y,
target.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 gsap.to
. 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:
gsap.to(point, {
x: x,
y: y,
ease: 'sine',
overwrite: true,
duration: 0.175,
onComplete() {
gsap.to(point, {
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
andy
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
andy
values match that of the target, “spring” the point back to its origin. I am usingelastic
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.