Canvassing the Neighbourhood
Those with epilepsy or sensitivity to flashing lights should be very careful running code from this article, as some of it results in strobe like effects.
I've been thinking a lot about backgrounds recently, specifically website backgrounds. A thrilling topic I know. At its most basic the background is simply white. Blank. There's nothing there. This is almost always terrible in my experience, as I'm a huge fan of flux, dark mode, and anything else that makes my monitors a little easier on the eyes. Navigating to a pure white background is somewhat akin to a searchlight pulling out a rusty knife and stabbing me repeatedly in the eye meat. Unpleasant in other words. One step up from this is a nice coloured background, something muted for my tastes, but ideally not pure black. A nice muted grey with a hint of blue purple or green is a good foundation. Moving on from this you can have a nice image as your background, something to do with your company, a nice neutral landscape etc etc. The only issue here is then one of bandwidth, images guzzle it down like nobody's business. To mitigate this you keep your image as small as possible, while having it look good and not be obviously pixelated. This requires multiple images served to different devices and can be done in a number of ways with varying levels of automation. For me the issue with images is that I have to either create an image with my very limited artistic skills, hire someone else to get me an image, or go and photograph something suitable, ideally borrowing a decent camera. All of which seemed kind of like a lot of effort
Cue option four, which I was reminded of when I visited a website using Node Garden. This is a dynamic background created on page load, with the only effort required being the coding. Now we're talking!
After a little consideration I decided I didn't want to just transplant Node Garden into my site, and instead envisioned something akin to a multicoloured larva lamp with a couple of muted colours undulating across the screen blending into one another. I'd not much experience with the canvas element prior to this, but knew you could do colour gradients. So, take a couple of circles, gradient from colour to transparency, throw them in a canvas, give them a slight shunt so they bounce around, and job done. Well, it was a little more difficult than that, but here's what I came up with.
First things first the canvas element on which to paint. I had a nosy around and swiftly came to the conclusion this was going to be a JS job. While I try to use base HTML and CSS where possible this seemed unlikely to be doable for this specific case. To start with lets get a simple gradient up and running
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 200
ctx.canvas.height = 200
let gradient = ctx.createRadialGradient(100, 100, 0, 100, 100, 100);
gradient.addColorStop(0, 'rgba(255,0,0,100)');
gradient.addColorStop(1, 'rgba(255,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 200, 200)
Here we get ahold of the canvas element by its ID, and explain we're looking to do some 2D drawing, then set the dimensions of it to a nice workable size. We're then creating a gradient from red all the way to complete transparency. We specify we want this to be circular and give the coordinates of the centre of a circle followed by its radius, then do the same again for a second circle. here we've specified 100,100 as our centre and no radius (so a singular point), and then for the outer circle specified a radius of 100. The next line specified that at our starting point we want a full rich red with no transparency, and the line after specifies that by we reach our outer circle we want this colour to have faded away entirely.
Finally we need to get this gradient onto the canvas, which we do by (somewhat counter intuitively filling a rectangle). The reason behind this is that while we've defined the gradient we've not applied it to anything. While we could fill a shape created using path commands, it is simplest just to use the inbuilt fillRect, making sure that our outer circle fits entirely within our 200 by 200 square. We've now got a "red light", and with a little work can start to clean up our code into a form that will work going forward.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 200;
ctx.canvas.height = 200;
const arrayColours = ['255,0,0','0,255,0','0,0,255'];
const r = 100;
function colourGradient(x,y,r,colour) {
let gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, `rgba(${colour},100)`);
gradient.addColorStop(1, `rgba(${colour},0)`);
return gradient
}
for (let i = 0; i < 3; i++) {
let x = Math.random() * ctx.canvas.width;
let y = Math.random() * ctx.canvas.height;
ctx.fillStyle = colourGradient(x,y,r,arrayColours[i]);
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
}
Here we've created randomly positioned lights, taking colours from a pre defined array, but they're not moving very quickly. To quickly resolve this we can add a loop as below.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 400;
ctx.canvas.height = 400;
const arrayColours = ['255,0,0','0,255,0','0,0,255'];
const r = 100;
function colourGradient(x,y,r,colour) {
let gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, `rgba(${colour},100)`);
gradient.addColorStop(1, `rgba(${colour},0)`);
return gradient
}
while (true) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let i = 0; i < 3; i++) {
let x = Math.random() * ctx.canvas.width;
let y = Math.random() * ctx.canvas.height;
ctx.fillStyle = colourGradient(x,y,r,arrayColours[i]);
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
}
}
Just one slight problem, if you try and run this your browser is likely to lock up and tell you "This page is slowing down Firefox" or "This page is unresponsive". The reason for this is you're telling it to create these objects as fast as possible with no break in between, and if you use the top command or open task manager you'll likely see that your browser of choice is using an ungodly amount of your CPU. To fix this we could add a small sleep between loops, but there is a much better way to sanely manage animations which is requestAnimationFrame. The main advantage of this over adding a timeout or using setInterval, is that the browser stops running animations in inactive tabs which is good for battery and CPU. As an added bonus there is some browser optimisation in place to make animations smoother, but the specifics all go a little over my head.
Modifying our code to use requestAnimationFrame we get the following:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 400;
ctx.canvas.height = 400;
const arrayColours = ['255,0,0','0,255,0','0,0,255'];
const r = 100;
function colourGradient(x,y,r,colour) {
let gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, `rgba(${colour},100)`);
gradient.addColorStop(1, `rgba(${colour},0)`);
return gradient
}
function repeatForever() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let i = 0; i < 3; i++) {
let x = Math.random() * ctx.canvas.width;
let y = Math.random() * ctx.canvas.height;
ctx.fillStyle = colourGradient(x,y,r,arrayColours[i]);
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
}
requestAnimationFrame(repeatForever);
}
repeatForever()
Switching between tabs we can see a noticeable drop in CPU usage when the tab with out background is not visible, not to mention an overall drop in CPU usage. So, a good start, but this looks nothing like what I'm envisioning, bearing more resemblance to an out of control rave. To fix this lets modify the code so that instead of each animation frame moving the lights to a random point it just moves it slightly in a random direction. Now that we've a basic understanding of all the working pieces this is going to be a fairly major rewrite. We now need to keep track of light positions and velocities, so we're going to go ahead and create a Light class and go from there.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 400;
ctx.canvas.height = 400;
let r = 300;
let lights = [];
const arrayColours = ['94, 63, 67', '85, 102, 88','107, 85, 112','107, 89, 77','72, 75, 102'];
function randomColour() {
return arrayColours[Math.floor(Math.random()*arrayColours.length)]
}
function colourGradient() {
let gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
gradient.addColorStop(0, `rgba(${randomColour()},100)`);
gradient.addColorStop(1, `rgba(${randomColour()},0)`);
return gradient
}
class Light {
constructor() {
this.x = Math.random() * ctx.canvas.width;
this.y = Math.random() * ctx.canvas.height;
this.vx = (Math.random() - 0.5);
this.vy = (Math.random() - 0.5);
this.gradient = colourGradient();
}
draw() {
ctx.save()
ctx.fillStyle = this.gradient;
ctx.translate(this.x,this.y)
ctx.fillRect(-r, -r, 2*r, 2*r)
ctx.restore()
}
}
light = new Light()
light.draw()
This will create a random coloured light, placed in a random position on our canvas. We've moved away from specifying our coordinate in the gradient, instead using the translate method to specify where the light is placed. You can also see we've defined vx and vy which are our components of velocity. They don't do anything yet but....
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 400;
ctx.canvas.height = 400;
let r = 300;
let lights = [];
const arrayColours = ['94, 63, 67', '85, 102, 88','107, 85, 112','107, 89, 77','72, 75, 102'];
function randomColour() {
return arrayColours[Math.floor(Math.random()*arrayColours.length)]
}
function colourGradient() {
let gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
gradient.addColorStop(0, `rgba(${randomColour()},100)`);
gradient.addColorStop(1, `rgba(${randomColour()},0)`);
return gradient
}
class Light {
constructor() {
this.x = Math.random() * ctx.canvas.width;
this.y = Math.random() * ctx.canvas.height;
this.vx = (Math.random() - 0.5);
this.vy = (Math.random() - 0.5);
this.gradient = colourGradient();
}
draw() {
ctx.save()
ctx.fillStyle = this.gradient;
ctx.translate(this.x,this.y)
ctx.fillRect(-r, -r, 2*r, 2*r)
ctx.restore()
}
move() {
this.x += this.vx
this.y += this.vy
}
}
function repeatForever() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
light.move()
light.draw();
requestAnimationFrame(repeatForever);
}
light = new Light()
light.draw()
requestAnimationFrame(repeatForever);
Now our light gradually moves in a random direction, and keeps going.....and going and going. Hmm so lets add some quick logic to bounce off the edges.
...
move() {
if (0 > this.x + this.vx || this.x + this.vx > ctx.canvas.width) {
this.vx *= -1;
}
if (0 > this.y + this.vy || this.y + this.vy > ctx.canvas.height) {
this.vy *= -1;
}
this.x += this.vx
this.y += this.vy
}
...
Here we check if our change in position would take it over an edge, and if it would we change the direction of velocity. Due to simply reversing the direction we end up with the light moving slightly more than it should at the edge (as it's a full reversal rather than using the amount left after it hit the edge to rebound) however with the small velocities we're using it matters little and in a much prettier calculation. So now we have a bouncy little ball in a canvas box. You may have noticed an unused array in the previous code, we're now going to use that to create a few more lights and have them all bounce at once
...
function repeatForever() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
lights.forEach((light) => {
light.move()
light.draw()
});
requestAnimationFrame(repeatForever);
}
...
function createLights(n) {
for (let i = 0; i < n; i++) {
const temp = new Light(i);
lights.push(temp);
}
}
...
createLights(10)
requestAnimationFrame(repeatForever);
...
Now we're talking. Now this is pretty much what we're looking for, apart from one small issue. I'm running this on a pretty old laptop currently, but I'm still not happy with the CPU usage. I've changed the number of lights, looked into animating less frames and I've still not found a solution that doesn't look janky while keeping the CPU cost down. The solution I've come up with is simple, don't animate the background. While originally I was enamoured by the idea of an undulating background I quite like the static background. This of course means that a large portion of the code here is now obsolete, so with that in mind lets keep the animation code but make it optional. We can have the background be created on page load, and then set up a listener to start/stop the animation. People aren't forced into running CPU munching animation, and it can be a cool little Easter egg for those who have read this article.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext("2d");
ctx.canvas.width = 400;
ctx.canvas.height = 400;
let r = 400;
let lights = [];
let running = 0;
const arrayColours = ['94, 63, 67', '85, 102, 88','107, 85, 112','107, 89, 77','72, 75, 102'];
function randomColour() {
return arrayColours[Math.floor(Math.random()*arrayColours.length)]
}
function colourGradient() {
let gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
gradient.addColorStop(0, `rgba(${randomColour()},100)`);
gradient.addColorStop(1, `rgba(${randomColour()},0)`);
return gradient
}
class Light {
constructor() {
this.x = Math.random() * ctx.canvas.width;
this.y = Math.random() * ctx.canvas.height;
this.vx = (Math.random() - 0.5)*10;
this.vy = (Math.random() - 0.5)*10;
this.gradient = colourGradient();
}
update() {
ctx.save()
ctx.fillStyle = this.gradient;
ctx.translate(this.x,this.y)
ctx.fillRect(-r, -r, 2*r, 2*r)
ctx.restore()
if (0 > this.x + this.vx || this.x + this.vx > ctx.canvas.width) {
this.vx *= -1;
}
if (0 > this.y + this.vy || this.y + this.vy > ctx.canvas.height) {
this.vy *= -1;
}
this.x += this.vx
this.y += this.vy
}
}
function updateLights() {
lights.forEach((light) => {
light.update()
});
}
function repeatForever() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
updateLights();
id = requestAnimationFrame(repeatForever);
}
function createLights(n) {
for (let i = 0; i < n; i++) {
const temp = new Light();
lights.push(temp);
}
}
createLights(10)
updateLights()
document.addEventListener('keyup', (e) => {
if (e.keyCode == 83 && running == 1) {
cancelAnimationFrame(id);
running = 0;
}
else if (e.keyCode == 83) {
requestAnimationFrame(repeatForever);
running = 1;
}
});
And there we go. There's still a little bit of tweaking to do, generating radius and number of lights based on screen size. Deciding how I want to manage screen resizing, possibly some logic to make sure there is full coverage with no grey peaking through, cleaning up the event listener etc etc.
I'm pretty happy with the end result and the knowledge on the canvas element gained. If you want to animate the background simply hit "s" on your keyboard. As it stands there's no neat option for mobile users, but then there's barely enough margin to see the background either.