DEV Community

loading...
Cover image for Create a generative landing page & WebGL powered background animation

Create a generative landing page & WebGL powered background animation

georgedoescode profile image George Francis ・14 min read

Recently I took a trip to the faraway land of dribbble and saw something magical. There were fuzzy orbs and beautiful, glass-like interfaces floating around everywhere. Serene! 

This got me thinking. Wouldn't it be cool to create a generative landing page in this style? 


The end result 

First of all, here's a kind of visual TL;DR.

You can check out a full-page example here, too.

The color palette is random within constraints. The colorful orbs move with a mind of their own. These elements of randomness are what make our landing page generative.

If generative art/design is new to you, here is an excellent primer from Ali Spittel & James Reichard.

Like what you see? Let's build!


Prerequisites

To get the most out of this tutorial you will need to be comfortable writing HTML, CSS, and JavaScript. 

If you have read “WebGL” and fallen into a state of shader-induced panic, don't worry. We will be using PixiJS to abstract away the scary stuff. This tutorial will serve as a nice introduction to Pixi if you haven't used it before, too. 


Creating the background animation

The first thing we are going to build is the orbs. To create them, we are going to need some libraries/packages. Let's get the boring stuff out of the way first and add them to the project. 

Package overview

Here's a quick summary of the libraries/packages we will be using. 

  • PixiJS - A powerful graphics library built on WebGL, we will use it to render our orbs.
  • KawaseBlurFilter - A PixiJS filter plugin for ultra smooth blurs.
  • SimplexNoise - Used to generate a stream of self-similar random numbers. More on this shortly.
  • hsl-to-hex - A tiny JS utility for converting HSL colors to HEX.
  • debounce - A  JavaScript debounce function.

Package installation

If you are following along on CodePen, add the following imports to your JavaScript file and you are good to go:

import * as PIXI from "https://cdn.skypack.dev/pixi.js";
import { KawaseBlurFilter } from "https://cdn.skypack.dev/@pixi/filter-kawase-blur";
import SimplexNoise from "https://cdn.skypack.dev/simplex-noise";
import hsl from "https://cdn.skypack.dev/hsl-to-hex";
import debounce from "https://cdn.skypack.dev/debounce";
Enter fullscreen mode Exit fullscreen mode

If you are hanging out in your own environment, you can install the required packages with:

npm i pixi.js @pixi/filter-kawase-blur simplex-noise hsl-to-hex debounce
Enter fullscreen mode Exit fullscreen mode

You can then import them like so:

import * as PIXI from "pixi.js";
import { KawaseBlurFilter } from "@pixi/filter-kawase-blur";
import SimplexNoise from "simplex-noise";
import hsl from "hsl-to-hex";
import debounce from "debounce";
Enter fullscreen mode Exit fullscreen mode

Note: Outside of CodePen you will need a build tool such as Webpack or Parcel to handle these imports.

A blank (Pixi) canvas 

Awesome, we now have everything we need to get started. Let's kick things off by adding a <canvas> element to our HTML:

<canvas class="orb-canvas"></canvas>
Enter fullscreen mode Exit fullscreen mode

Next, we can create a new Pixi instance with the canvas element as it's “view” (where Pixi will render). We will call our instance app:

// Create PixiJS app
const app = new PIXI.Application({
  // render to <canvas class="orb-canvas"></canvas>
  view: document.querySelector(".orb-canvas"),
  // auto adjust size to fit the current window
  resizeTo: window,
  // transparent background, we will be creating a gradient background later using CSS
  transparent: true
});
Enter fullscreen mode Exit fullscreen mode

If you inspect the DOM and resize the browser, you should see the canvas element resize to fit the window. Magic! 

Some helpful utilities 

Before going any further, we should add some utility functions to our JavaScript.

// return a random number within a range
function random(min, max) {
  return Math.random() * (max - min) + min;
}

// 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

If you have followed any of my tutorials before, you might be familiar with these already. I'm a little obsessed...

random will return a random number within a limited range. For example, “Give me a random number between 5 and 10”.

map takes a number from one range and maps it to another. For example, if a number (0.5) usually exists in a range between 0 - 1 and we map it to a range of 0 - 100, the number becomes 50. 

I encourage experimenting with these two utilities a little if they are new to you. They will be useful companions in your generative journey! Pasting them into the console and experimenting with the output is a great place to start. 

Creating the Orb class

Now, we should have everything we need to create our orb animation. To start, let's create an Orb class:

// Orb class
class Orb {
  // Pixi takes hex colors as hexidecimal literals (0x rather than a string with '#')
  constructor(fill = 0x000000) {
    // bounds = the area an orb is "allowed" to move within
    this.bounds = this.setBounds();
    // initialise the orb's { x, y } values to a random point within it's bounds
    this.x = random(this.bounds["x"].min, this.bounds["x"].max);
    this.y = random(this.bounds["y"].min, this.bounds["y"].max);

    // how large the orb is vs it's original radius (this will modulate over time)
    this.scale = 1;

    // what color is the orb?
    this.fill = fill;

    // the original radius of the orb, set relative to window height
    this.radius = random(window.innerHeight / 6, window.innerHeight / 3);

    // starting points in "time" for the noise/self similar random values
    this.xOff = random(0, 1000);
    this.yOff = random(0, 1000);
    // how quickly the noise/self similar random values step through time
    this.inc = 0.002;

    // PIXI.Graphics is used to draw 2d primitives (in this case a circle) to the canvas
    this.graphics = new PIXI.Graphics();
    this.graphics.alpha = 0.825;

    // 250ms after the last window resize event, recalculate orb positions.
    window.addEventListener(
      "resize",
      debounce(() => {
        this.bounds = this.setBounds();
      }, 250)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Our Orb is a simple circle that exists in a 2d space. 

It has an x and a y value, a radius, a fill color, a scale value (how large it is vs its original radius) and a set of bounds. Its bounds define the area it can move around in, like a set of virtual walls. This will stop the orbs from getting too close to our text.

You may notice the use of a non-existent setBounds function in the snippet above. This function will define the virtual constraints our orbs exist within.  Let's add it to the Orb class:

setBounds() {
  // how far from the { x, y } origin can each orb move
  const maxDist =
      window.innerWidth < 1000 ? window.innerWidth / 3 : window.innerWidth / 5;
  // the { x, y } origin for each orb (the bottom right of the screen)
  const originX = window.innerWidth / 1.25;
  const originY =
      window.innerWidth < 1000
      ? window.innerHeight
      : window.innerHeight / 1.375;

  // allow each orb to move x distance away from it's { x, y }origin
  return {
      x: {
      min: originX - maxDist,
      max: originX + maxDist
      },
      y: {
      min: originY - maxDist,
      max: originY + maxDist
      }
  };
}
Enter fullscreen mode Exit fullscreen mode

OK, great. This is coming together! Next up, we should add an update and a render function to our Orb class. Both of these functions will run on each animation frame. More on this in a moment.

The update function will define how the orb's position and size should change over time. The render function will define how the orb should display itself on-screen.

First, here is the update function:

update() {
  // self similar "psuedo-random" or noise values at a given point in "time"
  const xNoise = simplex.noise2D(this.xOff, this.xOff);
  const yNoise = simplex.noise2D(this.yOff, this.yOff);
  const scaleNoise = simplex.noise2D(this.xOff, this.yOff);

  // map the xNoise/yNoise values (between -1 and 1) to a point within the orb's bounds
  this.x = map(xNoise, -1, 1, this.bounds["x"].min, this.bounds["x"].max);
  this.y = map(yNoise, -1, 1, this.bounds["y"].min, this.bounds["y"].max);
  // map scaleNoise (between -1 and 1) to a scale value somewhere between half of the orb's original size, and 100% of it's original size
  this.scale = map(scaleNoise, -1, 1, 0.5, 1);

  // step through "time"
  this.xOff += this.inc;
  this.yOff += this.inc;
}
Enter fullscreen mode Exit fullscreen mode

In order for this function to run, we must also define simplex. To do so, add the following snippet anywhere before the Orb class definition:

// Create a new simplex noise instance
const simplex = new SimplexNoise();
Enter fullscreen mode Exit fullscreen mode

There's a lot of “noise” talk going on here. I realize that for some folks this will be an unfamiliar concept. 

I won't be going deep on noise in this tutorial, but I would recommend this video by Daniel Shiffman as a primer. If you are new to the concept of noise - pause this article, check out the video, and pop back!

In a nutshell, though, noise is a great way of generating _ self-similar_ random numbers. These numbers are amazing for animation, as they create smooth yet unpredictable movement. 

Here's an image from The Nature of Code showing the difference between traditional random (e.g. Math.random() and noisy random values: 

A ragged, random distribution compared to a smooth noisy one

The update function here uses noise to modulate the orb's x, y, and scale properties over time. We pick out noise values based on our xOff and yOff positions. We then use map to scale the values (always between -1 and 1) to new ranges.

The result of this? The orb will always drift within its bounds. Its size is random within constraints. The orb's behavior is unpredictable. There are no keyframes or fixed values here. 

This is all well and good, but we still can't see anything! Let's fix that by adding the render function to our Orb class:

render() {
  // update the PIXI.Graphics position and scale values
  this.graphics.x = this.x;
  this.graphics.y = this.y;
  this.graphics.scale.set(this.scale);

  // clear anything currently drawn to graphics
  this.graphics.clear();

  // tell graphics to fill any shapes drawn after this with the orb's fill color
  this.graphics.beginFill(this.fill);
  // draw a circle at { 0, 0 } with it's size set by this.radius
  this.graphics.drawCircle(0, 0, this.radius);
  // let graphics know we won't be filling in any more shapes
  this.graphics.endFill();
}
Enter fullscreen mode Exit fullscreen mode

render  will draw a new circle to our canvas each frame. 

You may notice that the circle's x and y values are both 0. This is because we are moving the graphics element itself, rather than the circle within it. 

Why is this? 

Imagine that you wanted to expand on this project, and render a more complex orb. Your new orb is now comprised of > 100 circles. It is simpler to move the entire graphics instance than to move every element within it. This may give you some performance gains, too.

Creating some orbs!

It's time to put our Orb class to good use. Let's create 10 brand new orb instances, and pop them into an orbs array:

// Create orbs
const orbs = [];

for (let i = 0; i < 10; i++) {
  // each orb will be black, just for now
  const orb = new Orb(0x000000);
  app.stage.addChild(orb.graphics);

  orbs.push(orb);
}
Enter fullscreen mode Exit fullscreen mode

We are calling app.stage.addChild to add each graphics instance to our canvas. This is akin to calling document.appendChild() on a DOM element. 

Animation! Or, no animation?

Now that we have 10 new orbs, we can start to animate them. Let's not assume everyone wants a moving background, though. 

When you are building this kind of page, it is crucial to respect the user's preferences. In our case, if the user has prefers-reduced-motion set, we will render a static background. 

Here's how we can set up a Pixi animation loop that will respect the user's preferences:

// Animate!
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
  app.ticker.add(() => {
    // update and render each orb, each frame. app.ticker attempts to run at 60fps
    orbs.forEach((orb) => {
      orb.update();
      orb.render();
    });
  });
} else {
  // perform one update and render per orb, do not animate
  orbs.forEach((orb) => {
    orb.update();
    orb.render();
  });
}
Enter fullscreen mode Exit fullscreen mode

When we call app.ticker.add(function), we tell Pixi to repeat that function at around 60 frames per second. In our case, if the user prefers reduced motion, we only run update and render our orbs once.  

Once you have added the above snippet, you should see something like this in the browser: 

A group of black circles

Hooray! Movement! Believe it or not, we are almost there. 

Adding the blur 

Our orbs are looking a little... harsh right now. Let's fix that by adding a blur filter to our Pixi canvas. This is actually very simple and will make a huge difference to our visual output.

Pop this line below your app definition:

app.stage.filters = [new KawaseBlurFilter(30, 10, true)];
Enter fullscreen mode Exit fullscreen mode

Now, if you check out the browser you should see some much softer orbs! 

A group of blurry black circles

Looking great. Let's add some color. 


A Generative color palette using HSL

To introduce some color to our project, we are going to create a ColorPalette class. This class will define a set of colors we can use to fill in our orbs but also style the wider page.

I always use HSL when working with color. It's more intuitive than hex and lends itself rather well to generative work. Here's how:

class ColorPalette {
  constructor() {
    this.setColors();
    this.setCustomProperties();
  }

  setColors() {
    // pick a random hue somewhere between 220 and 360
    this.hue = ~~random(220, 360);
    this.complimentaryHue1 = this.hue + 30;
    this.complimentaryHue2 = this.hue + 60;
    // define a fixed saturation and lightness
    this.saturation = 95;
    this.lightness = 50;

    // define a base color
    this.baseColor = hsl(this.hue, this.saturation, this.lightness);
    // define a complimentary color, 30 degress away from the base
    this.complimentaryColor1 = hsl(
      this.complimentaryHue1,
      this.saturation,
      this.lightness
    );
    // define a second complimentary color, 60 degrees away from the base
    this.complimentaryColor2 = hsl(
      this.complimentaryHue2,
      this.saturation,
      this.lightness
    );

    // store the color choices in an array so that a random one can be picked later
    this.colorChoices = [
      this.baseColor,
      this.complimentaryColor1,
      this.complimentaryColor2
    ];
  }

  randomColor() {
    // pick a random color
    return this.colorChoices[~~random(0, this.colorChoices.length)].replace(
      "#",
      "0x"
    );
  }

  setCustomProperties() {
    // set CSS custom properties so that the colors defined here can be used throughout the UI
    document.documentElement.style.setProperty("--hue", this.hue);
    document.documentElement.style.setProperty(
      "--hue-complimentary1",
      this.complimentaryHue1
    );
    document.documentElement.style.setProperty(
      "--hue-complimentary2",
      this.complimentaryHue2
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We are picking 3 main colors. A random base color, and two complimentary. We pick our complementary colors by rotating the hue 30 and 60 degrees from the base.

We then set the 3 hues as custom properties in the DOM and define a randomColor function. randomColor returns a random Pixi-compatible HSL color each time it is run. We will use this for our orbs. 

Let's define a ColorPalette instance before we create our orbs:

const colorPalette = new ColorPalette();
Enter fullscreen mode Exit fullscreen mode

We can then give each orb a random fill on creation:

const orb = new Orb(colorPalette.randomColor());
Enter fullscreen mode Exit fullscreen mode

If you check the browser, you should now see some color! 

A group of colorful, blurry circles

If you inspect the root html element in the DOM, you should also see some custom properties have been set. We are now ready to add some markup and styles for the page. 


Building the rest of the page

Awesome! So our animation is complete. It looks great and is running real fast thanks to Pixi. Now we need to build the rest of the landing page. 

Adding the markup

First of all, let's add some markup to our HTML file:

<!-- Overlay -->
<div class="overlay">
  <!-- Overlay inner wrapper -->
  <div class="overlay__inner">
    <!-- Title -->
    <h1 class="overlay__title">
      Hey, would you like to learn how to create a
      <span class="text-gradient">generative</span> UI just like this?
    </h1>
    <!-- Description -->
    <p class="overlay__description">
      In this tutorial we will be creating a generative “orb” animation using pixi.js, picking some lovely random colors, and pulling it all together in a nice frosty UI.
      <strong>We're gonna talk accessibility, too.</strong>
    </p>
    <!-- Buttons -->
    <div class="overlay__btns">
      <button class="overlay__btn overlay__btn--transparent">
        Tutorial out Feb 2, 2021
      </button>
      <button class="overlay__btn overlay__btn--colors">
        <span>Randomise Colors</span>
        <span class="overlay__btn-emoji">🎨</span>
      </button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

There's nothing too crazy going on here, so I won't dig in too much. Let's move onto our CSS:

Adding the CSS

:root {
  --dark-color: hsl(var(--hue), 100%, 9%);
  --light-color: hsl(var(--hue), 95%, 98%);
  --base: hsl(var(--hue), 95%, 50%);
  --complimentary1: hsl(var(--hue-complimentary1), 95%, 50%);
  --complimentary2: hsl(var(--hue-complimentary2), 95%, 50%);

  --font-family: "Poppins", system-ui;

  --bg-gradient: linear-gradient(
    to bottom,
    hsl(var(--hue), 95%, 99%),
    hsl(var(--hue), 95%, 84%)
  );
}

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

html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  max-width: 1920px;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 2rem;
  font-family: var(--font-family);
  color: var(--dark-color);
  background: var(--bg-gradient);
}

.orb-canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: -1;
}

strong {
  font-weight: 600;
}

.overlay {
  width: 100%;
  max-width: 1140px;
  max-height: 640px;
  padding: 8rem 6rem;
  display: flex;
  align-items: center;
  background: rgba(255, 255, 255, 0.375);
  box-shadow: 0 0.75rem 2rem 0 rgba(0, 0, 0, 0.1);
  border-radius: 2rem;
  border: 1px solid rgba(255, 255, 255, 0.125);
}

.overlay__inner {
  max-width: 36rem;
}

.overlay__title {
  font-size: 1.875rem;
  line-height: 2.75rem;
  font-weight: 700;
  letter-spacing: -0.025em;
  margin-bottom: 2rem;
}

.text-gradient {
  background-image: linear-gradient(
    45deg,
    var(--base) 25%,
    var(--complimentary2)
  );
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  -moz-background-clip: text;
  -moz-text-fill-color: transparent;
}

.overlay__description {
  font-size: 1rem;
  line-height: 1.75rem;
  margin-bottom: 3rem;
}

.overlay__btns {
  width: 100%;
  max-width: 30rem;
  display: flex;
}

.overlay__btn {
  width: 50%;
  height: 2.5rem;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--light-color);
  background: var(--dark-color);
  border: none;
  border-radius: 0.5rem;
  cursor: not-allowed;
  transition: transform 150ms ease;
  outline-color: hsl(var(--hue), 95%, 50%);
}

.overlay__btn--colors:hover {
  transform: scale(1.05);
  cursor: pointer;
}

.overlay__btn--transparent {
  background: transparent;
  color: var(--dark-color);
  border: 2px solid var(--dark-color);
  border-width: 2px;
  margin-right: 0.75rem;
  outline: none;
}

.overlay__btn-emoji {
  margin-left: 0.375rem;
}

@media only screen and (max-width: 1140px) {
  .overlay {
    padding: 8rem 4rem;
  }
}

@media only screen and (max-width: 840px) {
  body {
    padding: 1.5rem;
  }

  .overlay {
    padding: 4rem;
    height: auto;
  }

  .overlay__title {
    font-size: 1.25rem;
    line-height: 2rem;
    margin-bottom: 1.5rem;
  }

  .overlay__description {
    font-size: 0.875rem;
    line-height: 1.5rem;
    margin-bottom: 2.5rem;
  }
}

@media only screen and (max-width: 600px) {
  .overlay {
    padding: 1.5rem;
  }

  .overlay__btns {
    flex-wrap: wrap;
  }

  .overlay__btn {
    width: 100%;
    font-size: 0.75rem;
    margin-right: 0;
  }

  .overlay__btn:first-child {
    margin-bottom: 1rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

The key part of this stylesheet is defining the custom properties in :root. These custom properties make use of the values we set with our ColorPalette class. 

Using the 3 hue custom properties defined already, we create the following: 

  • --dark-color - To use for all our text and primary button styles,  this is almost black with a hint of our base hue. This helps make our color palette feel coherent.
  • --light-color - To use in place of pure white. This is much the same as the dark color, almost white with a hint of our base hue.
  • --complimentary1 - Our first complimentary color, formatted to CSS friendly HSL.
  • --complimentary2 - Our second complementary color, formatted to CSS friendly HSL.
  • --bg-gradient - A subtle linear gradient based on our base hue. We use this for the page background. 

We then apply these values throughout our UI. For button styles, outline colors, even a gradient text effect. 

A note on accessibility 

In this tutorial, we are almost setting our colors and letting them run free. In this case, we should be ok given the design choices we have made. In production, though, always make sure you meet at least WCAG 2.0 color contrast guidelines.


Randomising the colors in real-time

Our UI and background animation are now complete. It's looking great, and you will see a new color palette/orb animation each time you refresh the page. 

It would be good if we could randomize the colors without refreshing, though. Luckily, thanks to our custom properties/color palette setup, this is simple. 

Add this small snippet to your JavaScript:

document
  .querySelector(".overlay__btn--colors")
  .addEventListener("click", () => {
    colorPalette.setColors();
    colorPalette.setCustomProperties();

    orbs.forEach((orb) => {
      orb.fill = colorPalette.randomColor();
    });
  });
Enter fullscreen mode Exit fullscreen mode

With this snippet, we are listening for a click event on our primary button. On click, we generate a new set of colors, update the CSS custom properties, and set each orb's fill to a new value.

As CSS custom properties are reactive, our entire UI will update in real-time. Powerful stuff.


That's all folks

Hooray, we made it! I hope you had fun and learned something from this tutorial. 

Random color palettes may be a tad experimental for most applications, but there's a lot to take away here. Introducing an element of chance could be a great addition to your design process.

You can never go wrong with a generative animation, either. 


Follow on on Twitter @georgedoescode for more creative coding/front-end development content.  

This article and demo took around 12 hours to create. If you would like to support my work you can buy me a ☕ ❤️

Discussion (12)

pic
Editor guide
Collapse
inhuofficial profile image
InHuOfficial

A ❤ from me for including prefers-reduced-motion and considering people with vestibular (movement) disorders!

Collapse
inhuofficial profile image
InHuOfficial • Edited

I had a quick fiddle seeing if a similar effect could be made purely with filters and CSS. With a bit of work I think it could be made to look just as good without having to load any JS libraries.

If I get chance I will see if I can make a closer copy of this but if someone wants to have a fiddle with it this is where I got so far.

Collapse
georgedoescode profile image
George Francis Author

Ah! Thank you ❤️

Collapse
ben profile image
Ben Halpern

Yeah, this whole post is thorough and brilliant.

Thread Thread
georgedoescode profile image
George Francis Author

Thank you, Ben! 🙌

Collapse
eecolor profile image
EECOLOR

I think it's a good idea to let people know that you need to enabled hardware acceleration and experimental WebGL if you are using Chrome:

chrome://settings > Advanced > System > Use hardware acceleration when available
chrome://flags > WebGL Draft Extensions

Collapse
georgedoescode profile image
George Francis Author

I think this should be enabled by default, no?

Collapse
eecolor profile image
EECOLOR

On my windows machine it as not. The weird thing however was that when I disabled the experimental setting and relaunched Chrome it was still working.

So maybe mention it for people who do not see the colors.

Thread Thread
georgedoescode profile image
George Francis Author

Oh, that’s very odd. Not something I can replicate. Thanks for the heads up, though! Definitely worth keeping in mind 👌

Collapse
groundhogs profile image
Ground Hogs

Is pixi still and 800kb dep?

Collapse
georgedoescode profile image
George Francis Author

Good question! I don't think it's that large. Checking out pixijs.io/customize/, a bundle with everything included (apart from a canvas fallback) comes in around 393kb. I think one could customise their build and end up with a bundle size of < 200kb, though, for sure. It's still quite large for a single animation and in reality, I would likely load PIXI dynamically after the rest of the page had rendered, and I had checked the user's motion preferences.

Collapse
groundhogs profile image
Ground Hogs

Great to see pixi advance this much! I see that with all bells and whistles included it's about 400kb, which is acceptable! If only the docs were nicer, I'd prob see myself using it for a lot of things