Create a generative landing page & WebGL powered background animation
Tags: webdev, tutorial, javascript, css
Published: 2021-02-08 11:25:51

Recently I took a trip to the faraway land of [dribbble](https://www.dribbble.com) 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.

{% codepen https://codepen.io/georgedoescode/pen/XWNmvro %}

[You can check out a full-page example here, too.](https://codepen.io/georgedoescode/full/XWNmvro)

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](https://dev.to/aspittel/intro-to-generative-art-2hi7) from [Ali Spittel](https://twitter.com/ASpittel) & [James Reichard](https://twitter.com/1800THEHIVE).

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](https://www.pixijs.com/) 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](https://www.npmjs.com/package/pixi.js/v/5.0.0-rc) - A powerful graphics library built on WebGL, we will use it to render our orbs.
*   [KawaseBlurFilter](https://www.npmjs.com/package/@pixi/filter-kawase-blur) - A PixiJS filter plugin for ultra smooth blurs.
*   [SimplexNoise](https://www.npmjs.com/package/simplex-noise) - Used to generate a stream of self-similar random numbers. More on this shortly.
*   [hsl-to-hex](https://www.npmjs.com/package/hsl-to-hex) - A tiny JS utility for converting HSL colors to HEX.
*   [debounce](https://www.npmjs.com/package/debounce) - A  JavaScript [debounce function.](https://davidwalsh.name/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:

```javascript
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";
```

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

```bash
npm i pixi.js @pixi/filter-kawase-blur simplex-noise hsl-to-hex debounce
```

You can then import them like so:

```javascript
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";
```

_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:

```html
<canvas class="orb-canvas"></canvas>
```

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`:

```javascript
// 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
});
```

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.

```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;
}
```

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:

```javascript
// 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)
    );
  }
}
```

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:

```javascript
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
      }
  };
}
```

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:

```javascript
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;
}
```

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:

```javascript
// Create a new simplex noise instance
const simplex = new SimplexNoise();
```

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](https://www.youtube.com/watch?v=8ZEMLCnn8v0) 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](https://natureofcode.com/) showing the difference between traditional random (e.g. `Math.random()` and noisy random values: 

![A ragged, random distribution compared to a smooth noisy one](https://dev-to-uploads.s3.amazonaws.com/i/7l8ej48s9jal1i9xo05u.png)

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:

```javascript
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();
}
```

`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:

```javascript
// 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);
}
```

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:

```javascript
// 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();
  });
}
```

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](https://dev-to-uploads.s3.amazonaws.com/i/kea24zae2xtxvj353353.png)

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:

```javascript
app.stage.filters = [new KawaseBlurFilter(30, 10, true)];
```

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

![A group of blurry black circles](https://dev-to-uploads.s3.amazonaws.com/i/ce1ouotst33m27asxtim.png)

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:

```javascript
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
    );
  }
}
```

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:

```javascript
const colorPalette = new ColorPalette();
```

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

```javascript
const orb = new Orb(colorPalette.randomColor());
```

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

![A group of colorful, blurry circles](https://dev-to-uploads.s3.amazonaws.com/i/gr5snevpd1leplkhii6n.png)

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:

```html
<!-- 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>
```

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

```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;
  }
}
```

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](https://webaim.org/articles/contrast/)__.

---

## 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:

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

    orbs.forEach((orb) => {
      orb.fill = colorPalette.randomColor();
    });
  });
```
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](https://twitter.com/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 ☕](https://ko-fi.com/georgedoescode) ❤️_