One of the first things I ever programmed was a snow animation. That was over 20 years ago and written in QBasic, but I always come back to the simplicity of a serene snowfall scene. So, when the opportunity arose this year to spice up a simple web page sharing some holiday photos with family and friends, I decided to go ahead and "reboot" one of my very first software projects, and bring it to the web in style.
This post is written as a tutorial, building up our snow scene through iteration. If you'd like to skip all that, you can head down to the "final" CodePen immediately.
The Tech & Approach
The goal here is to create a performant snow animation that has a natural look and feel and doesn't physically get in the way of the content on the page.
The basic approach to animation in general is to have a subject or subjects, and update their look many, many times a second. Luckily, snow flakes are a pretty simple subject, and the web has some built in mechanisms to handle animation, framerate, and drawing.
So, our basic approach will be to have an Array of snow flake data, which we will iterate over each snow flake, update their position, and draw them within an HTML5 Canvas for each frame, which will be controlled using requestAnimationFrame.
Let's start by defining our snowflake. In order for our snowflake to be drawn, we need to know where it exists on our canvas. For this, we'll start with a simple interface for its x and y position:
interface SnowFlake {
/** The current x position. */
x: number;
/** The current y position. */
y: number;
}
Note: We're using TypeScript here. Though, this whole project is quite simple and can easily be converted to raw JavaScript without too much effort.
Next, we need a <canvas>
element, and attach it to the dom. We'll also add some styles so it fills the viewport, stays-put with any scrolling, is on top of any content, and doesn't get in the way of any clicks.
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.width = window.innerHeight;
canvas.style.position = 'fixed';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '999';
document.body.appendChild(canvas);
Also, since we're using a whole lot of random values, we'll make a couple quick helper functions:
/** Helper function returning a decimal between min/max. */
function random(min: number, max: number) {
return Math.random() * (max - min) + min;
}
/** Helper function returning an int between & inclusive of min/max. */
function randomInt(min: number, max: number) {
return (Math.floor(Math.random() * (max - min + 1)) + min);
}
Now, let's generate all our snow flakes with some random x
and y
values.
const flakes: SnowFlake[] = [];
const numOfFlakes = randomInt(300, 600);
for (var i = 0; i < numOfFlakes; i++) {
flakes.push({
x: randomInt(0, canvas.width),
y: randomInt(0, canvas.height),
});
}
Finally, we'll create our draw
function which we'll call over and over again using window.requestAnimationFrame
.
const ctx = canvas.getContext('2d')!;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.beginPath();
flakes.forEach((flake) => {
// Draw our flake at its current x/y
ctx.moveTo(flake.x, flake.y);
ctx.arc(flake.x, flake.y, 2, 0, Math.PI * 2);
// Update our flake's next x/y.
flake.y += 1;
flake.x += 1;
// If our snowflake goes off the left, right or bottom,
// move it to the opposite side.
if (flake.x > canvas.width) {
flake.x = 0;
} else if (flake.x < 0) {
flake.x = canvas.width;
} else if (flake.y > canvas.height) {
flake.x = randomInt(0, canvas.width);
flake.y = -2;
}
});
// Fill in all the arc paths' we've just created...
ctx.fill();
// ...and schedule us to do it all over again.
window.requestAnimationFrame(draw);
}
And finally, kick it off:
window.requestAnimationFrame(draw);
That's it! We now have snow!
Wait... Is that really it?
Of course not! There's so much more we can do.
Alright, let's keep going. I'm going to walk us through a bunch of improvements and you should follow along and see how our snow scene starts to take shape.
1) Let's add some dynamic sizes.
We're going to add a radius
field to our SnowFlake
interface, set a random size when we generate our flakes, and use this field in our draw
function (replacing the hardcoded "2" there).
interface SnowFlake {
// ...
/** The radius in pixels. */
radius: number;
}
// ... and our new flake generation will look like the following:
flakes.push({
x: randomInt(0, canvas.width),
y: randomInt(0, canvas.height),
radius: random(.25, 2),
});
// ...and we'll use that in our draw method for the arc:
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
Awesome! When you plug these changes in you'll see much more dynamically sized snow. It's already feeling better!
But there's still something missing... they all still move the same direction and at the same speed...
2) Let's add some dynamic movements!
Alright, we're going to do something similar to make the snow move more randomly. Just like we added radius
above, we'll now add drop
and sway
fields as well. These will be used to offset the general direction we're moving so we get some flakes that move a little faster, both vertically (drop) and horizontally (sway).
interface SnowFlake {
// ...
/** The radius in pixels. */
radius: number;
/** A value to add to y movement to speed/slow itself. */
drop: number;
/** A value to add to x movement to speed/slow itself. */
sway: number;
}
// ... and our new flake generation will look like the following.
// (Similarly, we'll inflate our range and divide by 100 to get
// a number between -.300 and .300 with a precision of three).
flakes.push({
x: randomInt(0, canvas.width),
y: randomInt(0, canvas.height),
radius: random(.25, 2),
sway: random(-.3, .3),
drop: random(-.3, .3),
});
// ...and now, in our `draw` method, when we update our position,
// we'll add the sway and drop:
flake.y += 1 + flake.drop;
flake.x += 1 + flake.sway;
And now we have dynamically falling and swaying snowflakes. It looks much much better already! There's one last thing we can do to smooth out one rough edge...
4) Let's fix resizing.
You may have noticed if you resize the window, the snow stretches instead of applying the extra/lost room. This is because the canvas' CSS is stretching the element, but its set width and height don't update, so our drawing becomes stretches or squished.
This is actually a simple fix, which we do so by setting the width
and height
attributes of the canvas to it's new size when we resize:
// Update the canvas width/height data when the window resizes.
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
Now that's it! We have dynamic, more natural feeling snow!
Hold on... Is that really it?
You got me! There's always more we can do. But this is a terrific spot to stop and deliver. We'll call what we've just created the MVP. Wrap it up in a bow and push it to production :)
As I mentioned at the very beginning, I created my version of falling snow for a holiday webpage I was sharing with family and friends. What I actually built was a little slightly different than the tutorial walkthrough above, which I've simplified a bit.
Below, we have the my "final" CodePen, which has some additional enhancements like a gentle wind causing the snowflakes to slowly sway from left to right, etc. I also have it all in a class
instance with start
, pause
, and stop
methods, because that's how I enjoy encapsulating code. You're welcome to check it out below, and think up even more ways to make it better for next holiday season!
Top comments (2)
That's why I admire frontend developers!
this is nice, always used particlesjs for that ππ