DEV Community

loading...
Cover image for Build a smooth, animated blob using SVG + JS

Build a smooth, animated blob using SVG + JS

georgedoescode profile image George Francis Updated on ・10 min read

Hey there! Do you love gradients, fear hard edges, maybe own an enormous collection of lava lamps?

Oh… you do?

You’re in the right place, my friend!

I posted this CodePen on Twitter / Reddit land a couple of days ago:

Since posting, a few folks have asked for some info about how it was made, so I thought I would write a short tutorial on the process 👓.


Prerequisites ℹ️

This tutorial is geared towards people comfortable with JavaScript, HTML and CSS. A degree of familiarity with SVG is also handy here, although this could be a good opportunity to dip your toe into the SVG ocean if you haven’t before.


SVG markup

Let’s start off by adding some markup for our <svg> element.

The gradient (<linearGradient>)

One of the most important aspects of this sketch is the modulating gradient fill that you see within the blob. It is also a great starting point for our code:

<!-- Draw everything relative to a 200x200 canvas, this will then scale to any resolution -->
<svg viewBox="0 0 200 200">
  <defs>
    <!-- Our gradient fill #gradient -->
    <linearGradient id="gradient" gradientTransform="rotate(90)">
      <!-- Use CSS custom properties for the start / stop colors of the gradient -->
      <stop id="gradientStop1" offset="0%" stop-color="var(--startColor)" />
      <stop id="gradientStop2 " offset="100%" stop-color="var(--stopColor)" />
    </linearGradient>
  </defs>
</svg>
Enter fullscreen mode Exit fullscreen mode

If you aren’t too familiar with SVG, check out the MDN docs on linearGradient.

If you check out the code, you might notice I am using CSS custom properties for the start / stop values of the gradient, but they don’t have any values yet. This is fine, we are going to set them dynamically using JavaScript a little later.

The blob shape (<path>)

The blob shape you see is a single SVG <path>. <path> is a powerful SVG element that can be used to render a whole variety of shapes using a combination of curves and lines. I won’t get into it too much here to keep things brief, but here is a great primer on MDN.

Let’s add a <path /> element to our markup:

<svg viewBox="0 0 200 200">
  ...
  <!-- Add a path with an empty data attribute, fill it with the gradient we defined earlier -->
  <path d="" fill="url('#gradient')"></path>
</svg>
Enter fullscreen mode Exit fullscreen mode

Right now, the <path> element has an empty d attribute. d stands for data and is used to define what shape the path is. We are going to set this a little later in our JavaScript.


Style it out 💅

OK, so we have all the SVG markup we need! Nice. We shouldn’t need to touch any markup for the rest of this tutorial as we can simply manipulate our custom properties and update the <path> data attribute.

We could do with adding a little CSS though. Nothing too crazy, let’s just make sure our blob dimensions always fit the viewport and it is aligned perfectly centre-aligned:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  // align our svg to the center of the viewport both horizontally and vertically
  height: 100vh;
  display: grid;
  place-items: center;
}

svg {
  // a perfectly square <svg> element that will never overflow the viewport
  width: 90vmin;
  height: 90vmin;
}
Enter fullscreen mode Exit fullscreen mode

Note: I’m not actually defining any custom properties in the CSS itself, as we are going to set them dynamically using JavaScript shortly.


The main event 🚀

OK, awesome, we have added our markup and styles. We can’t see anything yet, but we have our blank canvas all set up and ready to start creating some beautiful blobs.

Adding the dependencies

In order to create our blob, we are going to need a few libraries:

  • @georgedoescode/spline: used to draw a smooth curve through a set of points
  • simplex-noise: used to generate a smooth, self-similar stream of random values (more on this later)

If you are using CodePen you can simply import these libraries like so:

import { spline } from "https://cdn.skypack.dev/@georgedoescode/spline@1.0.1";
import SimplexNoise from "https://cdn.skypack.dev/simplex-noise@2.4.0";
Enter fullscreen mode Exit fullscreen mode

If you have set up your own environment, you can install these packages with:

npm install simplex-noise @georgedoescode/spline
Enter fullscreen mode Exit fullscreen mode

And import them like so:

import { spline } from "@georgedoescode/spline";
import SimplexNoise from "simplex-noise";
Enter fullscreen mode Exit fullscreen mode

Note: if you are working in your own environment, you will most likely need a bundler such as Parcel or Webpack to handle these module imports.

DOM references

Now that we have installed and imported all of the dependencies we need, we should store some references to the DOM so that we can manipulate the elements a little later:

// our <path> element
const path = document.querySelector("path");
// used to set our custom property values
const root = document.documentElement;
Enter fullscreen mode Exit fullscreen mode

Creating the blob shape, an overview

Hooray, it’s time to start creating our blob shape!

First off, let me highlight the steps needed to create/animate the blob:

  1. Create 6 equally spaced points around the circumference of a circle
  2. Over time, change the { x, y } values of each point
  3. Draw a smooth curve through each point using spline()
  4. Repeat steps 2 + 3

Don’t worry if this seems a little crazy right now, all will become clear as we write our JavaScript!

Initialise the blob points

As mentioned in step 1 above, the first thing we need to do is create and store some { x, y } points plotted around the circumference of a circle. To do this, we can add a function createPoints():

function createPoints() {
  const points = [];
  // how many points do we need
  const numPoints = 6;
  // used to equally space each point around the circle
  const angleStep = (Math.PI * 2) / numPoints;
  // the radius of the circle
  const rad = 75;

  for (let i = 1; i <= numPoints; i++) {
    // x & y coordinates of the current point
    const theta = i * angleStep;

    const x = 100 + Math.cos(theta) * rad;
    const y = 100 + Math.sin(theta) * rad;

    // store the point
    points.push({
      x: x,
      y: y,
      /* we need to keep a reference to the point's original {x, y} coordinates 
      for when we modulate the values later */
      originX: x,
      originY: y,
      // more on this in a moment!
      noiseOffsetX: Math.random() * 1000,
      noiseOffsetY: Math.random() * 1000,
    });
  }

  return points;
}
Enter fullscreen mode Exit fullscreen mode

We can then initialise our blob points like so:

const points = createPoints();
Enter fullscreen mode Exit fullscreen mode

Let’s render something!

So we have some points plotted nicely around the circumference of a circle, but we still can’t see anything. I think it’s high time we change that.

Let’s add an animation loop using requestAnimationFrame:

(function animate() {
  requestAnimationFrame(animate);
})();
Enter fullscreen mode Exit fullscreen mode

This animate() function will call itself, then continue to do so roughly 60 times per second (this could vary based on different monitors/devices but most often it’s going to run at around 60fps). If you haven’t used requestAnimationFrame before, here are some useful docs.

Within the animate() loop, we can draw a smooth spline through all of our points:

(function animate() {
  // generate a smooth continuous curve based on points, using Bezier curves. spline() will return an SVG path-data string. The arguments are (points, tension, close). Play with tension and check out the effect!
  path.setAttribute("d", spline(points, 1, true));

  requestAnimationFrame(animate);
})();
Enter fullscreen mode Exit fullscreen mode

Once this line has been added, you should see a kind of almost circle shape appear on the screen. Delicious!

A simple blob shape

Note: about the spline() function

The spline function you see here is actually a Catmull-Rom spline. A Catmull-Rom spline is great for drawing organic shapes as it not only draws a smooth bezier curve through every { x, y } point, it also “closes” or loops back to its first point perfectly.

A quick primer on noise

Before we move onto the next step of animating our blob, it would be good to dip into the basics of “noise” and how it can be useful for animation.

In a nutshell, “noise” (commonly either Perlin or Simplex) is used to generate a self similar stream of random values. That is to say, each value returned is similar to the previous value.

By using noise we remove large changes between random values, which in our case would result in a rather jumpy animation.

Here’s an excellent diagram from Daniel Shiffman’s The Nature Of Code book that visually demonstrates the difference between the (technically) pseudo-random values generated using noise vs random values generated using a method such as Math.random() in JavaScript:

A comparison between noise and random values

It can be helpful to think about noise values as existing relative to a position in “time”. Here is another visual example from The Nature Of Code.

Noise values through time diagram

Remember these values from earlier?

points.push({
  ...
  noiseOffsetX: Math.random() * 1000,
  noiseOffsetY: Math.random() * 1000
});
Enter fullscreen mode Exit fullscreen mode

These are the starting “time” positions for each of our points. We start each point’s noise values in a random position to make sure they all move in a different way. Here’s what our animation would look like if they all started from the same point in time:

A moving blob on 1 axis

A little boring, right?

Note: if you would like to go deeper on noise, Daniel Shiffman can offer a far more in-depth explanation than I can over at https://natureofcode.com/book/introduction/.

Let’s animate!

Now, this is where things start to get interesting. It’s time to modulate each point in our shape’s { x, y } values based on a noisy random value.

Before we do this though, let’s add a quick utility function:

// map a number from 1 range to another
function map(n, start1, end1, start2, end2) {
  return ((n - start1) / (end1 - start1)) * (end2 - start2) + start2;
}
Enter fullscreen mode Exit fullscreen mode

This map() function is incredibly useful. It simply takes a value in one range and maps it to another.

For example: if we have a value of 0.5 that is usually between 0 and 1, and we map it to an output of 0 to 100, we will get a value of 50. If this is a little confusing, try copying the above function into dev tools and have a play!

Let’s also create a new SimplexNoise instance, add a noiseStep variable and define a quick noise() function:

const simplex = new SimplexNoise();

// how fast we progress through "time"
let noiseStep = 0.005;

function noise(x, y) {
  // return a value at {x point in time} {y point in time}
  return simplex.noise2D(x, y);
}
Enter fullscreen mode Exit fullscreen mode

Note: the above code should be added before our animate function!

noiseStep simply defines how quickly we progress through “time” for our noise values. A higher value will result in a much faster-moving blob.

Now that we have our map() and noise() functions, we can add the following to our animate() function/loop:

(function animate() {
  ...
  // for every point...
  for (let i = 0; i < points.length; i++) {
    const point = points[i];

    // return a pseudo random value between -1 / 1 based on this point's current x, y positions in "time"
    const nX = noise(point.noiseOffsetX, point.noiseOffsetX);
    const nY = noise(point.noiseOffsetY, point.noiseOffsetY);
    // map this noise value to a new value, somewhere between it's original location -20 and it's original location + 20
    const x = map(nX, -1, 1, point.originX - 20, point.originX + 20);
    const y = map(nY, -1, 1, point.originY - 20, point.originY + 20);

    // update the point's current coordinates
    point.x = x;
    point.y = y;

    // progress the point's x, y values through "time"
    point.noiseOffsetX += noiseStep;
    point.noiseOffsetY += noiseStep;
  }
})();
Enter fullscreen mode Exit fullscreen mode

Drumroll, please…

A wobbling blob shape

Aww yeah, check out that blobby goodness! Nice work.

Adding the gradient

We now have an awesome animated blob shape. The only thing missing is color! In order to create a beautiful gradient fill, we are going to:

  1. Choose a base hue based on another noise value (somewhere between 0 and 360)
  2. Choose another hue 60 degrees away from the base hue (thanks to Adam Argyle for this tip!)
  3. Assign the base hue to our custom property --startColor and the complementary hue to our custom property --stopColor
  4. Set the <body> background color to a darkened version of --stopColor
  5. (hopefully) Marvel at the gradient beauty!

To add this to our code, let’s first define a hueNoiseOffset variable above our animate loop (this is the hue’s position in “time”, just like our point’s noiseOffsetX/noiseOffsetY values but for 1 dimension)

let hueNoiseOffset = 0;
Enter fullscreen mode Exit fullscreen mode

We can then progress hueNoiseOffset through time as our animate() loop runs:

(function animate() {
  ...
  // we want the hue to move a little slower than the rest of the shape
  hueNoiseOffset += noiseStep / 6;
})();
Enter fullscreen mode Exit fullscreen mode

Now that hueNoiseOffset is moving nicely through time, we can add the following code to perform steps 2 / 4:

(function animate() {
  ...
  const hueNoise = noise(hueNoiseOffset, hueNoiseOffset);
  const hue = map(hueNoise, -1, 1, 0, 360);

  root.style.setProperty("--startColor", `hsl(${hue}, 100%, 75%)`);
  root.style.setProperty("--stopColor", `hsl(${hue + 60}, 100%, 75%)`);
  document.body.style.background = `hsl(${hue + 60}, 75%, 5%)`;
})();
Enter fullscreen mode Exit fullscreen mode

With a bit of luck, you should now see something like this:

A colorful morphing blob


Bonus round… Interaction! 👉

Our blob is all done! There is one more thing we could add though…

It would be cool if when you hover over the blob, it responded in some way. Perhaps by moving a little faster?

We can easily achieve this by simply increasing the noiseStep value when hovering over the blob:

document.querySelector("path").addEventListener("mouseover", () => {
  noiseStep = 0.01;
});

document.querySelector("path").addEventListener("mouseleave", () => {
  noiseStep = 0.005;
});
Enter fullscreen mode Exit fullscreen mode

When noiseStep moves quicker through time, so do our random noise values. This gives the impression of the shape moving faster. Neat!


Thank you for reading! 👋

I hope you enjoyed this article and learned something new. If you got a little stuck at any point, please check out the end result CodePen. If this isn’t quite enough to help you out, feel free to get in touch. I’m always happy to chat!

If you dig this content and would like to be kept up to date with more of my posts / CodePens / generative art material follow me on Twitter @georgedoescode ❤️

You can also support my tutorials by buying me a coffee ☕

Discussion (2)

pic
Editor guide
Collapse
budibase profile image
Budibase

This is great. Thanks for writing this up.

Collapse
georgedoescode profile image
George Francis Author

No worries! I'm glad you enjoyed it.