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";
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
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";
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>
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
});
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;
}
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)
);
}
}
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
}
};
}
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;
}
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();
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:
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();
}
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);
}
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();
});
}
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:
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)];
Now, if you check out the browser you should see some much softer orbs!
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
);
}
}
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();
We can then give each orb a random fill on creation:
const orb = new Orb(colorPalette.randomColor());
If you check the browser, you should now see some color!
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>
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;
}
}
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();
});
});
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 ☕ ❤️
Top comments (15)
A ❤ from me for including
prefers-reduced-motion
and considering people with vestibular (movement) disorders!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.
Ah! Thank you ❤️
Yeah, this whole post is thorough and brilliant.
Thank you, Ben! 🙌
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
I think this should be enabled by default, no?
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.
Oh, that’s very odd. Not something I can replicate. Thanks for the heads up, though! Definitely worth keeping in mind 👌
Hi @georgedoescode it seems like your codepen is not working correctly showing the colours? codepen.io/georgedoescode/pen/XWNmvro
Is pixi still and 800kb dep?
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.
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
This is awesome! What part of the ColorPalette code would I need to tweak to avoid the really bright yellows that come through?
Dear George, thank you for this article with clear concise explanation. I have been trying to figure out if it is possible to map an array of color to the orb array to have a chosen set of colors?