Release your inhibitions
Feel the rain on your skin
Approach the problem
The goal of this is to have a continuously animated rainfall that can interact with elements on the screen, and doesn't slow down processes with the animation.
To achieve this, I created a canvas that fills the entire parent container, and then I use a simple animation loop to draw the rain and splashes. We need to consider how we should run a loop animation, how to track rain drops, and how to quickly paint all of this to a canvas without causing performance issues.
We'll start by setting up our canvas and script. We'll need to ensure we have a canvas, then we'll set it to its container size and add a listener to adjust for changing screen sizes.
<canvas id="garden-canvas" class="w-full h-full"/>
<script type="module">const canvas = document.getElementById("garden-canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
console.log("no context");
}
function resizeCanvas() {
const container = canvas.parentElement;
if (container) {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
}
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
window.addEventListener("scroll", updateElementPositions);
</script>
Base Class
Lets start by considering our state! We'll need to keep track of each rain drop as it passes down the canvas. We can start with an empty class with a few states that we can use to track the rain drop's position, velocity, and size.
class RainDrop {
x = 0;
y = 0;
angle = Math.PI / 2 + Math.PI / 12;
velocity = 400;
radius = 2;
constructor(x, y, angle = Math.PI / 2 + Math.PI / 12) {
this.x = x;
this.y = y;
this.angle = angle;
}
update(deltaTime) {}
draw(ctx) {}
}
Notice that this class also has an update and draw function. We'll also add these to our splashes and run then within our animation loop itself.
Now we can add the drops to the canvas by tracking a main "state". Here, I decided to allow my canvas to keep numberOfDrops = 50 and a targetFPS = 45. Now we can work on our update and draw functions that will run on each frame.
For the update function, we'll use a simple sin + cos to calculate position. This will work well for moving the circular drop, but adding a small streak behind it gives a little more flair to the entire thing. So we'll also add a new trail state to track the last few position for our draw function later.
update(deltaTime) {
const speed = (this.velocity * deltaTime) / 1000; // Adjust speed based on delta time
// add current position to trail
this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > 1) {
this.trail.shift();
}
// save previous position
this.prevX = this.x;
this.prevY = this.y;
// set new position
this.y += Math.sin(this.angle) * speed;
this.x += Math.cos(this.angle) * speed;
}
Now we can add a draw function. This will draw an arc at the current position, and draw a line behind it
draw(ctx) {
ctx.fillStyle = mainFill;
// shadows are a fun but expensive flair
ctx.shadowColor = mainFill;
ctx.shadowBlur = 10;
if (this.trail.length > 0) {
ctx.strokeStyle = mainFill;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(this.trail[0]?.x, this.trail[0]?.y);
ctx.lineTo(this.x, this.y);
ctx.stroke();
}
ctx.beginPath();
ctx.fillStyle = mainFill;
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
Before we can start animating though, we need a way to reset rain drop that go off screen so they don't fall forever. To do this, lets update our `Update` function to catch when a drop has gone too far off screen and reset it to the top again.
update(deltaTime) {
//...updateFunc
if (this.y > canvas.height) {
this.reset();
}
}
reset() {
this.y = -10;
this.x = Math.random() * canvas.width;
this.trail = [];
}
Animation Loop
Next we'll add our actual animation loop function. This will leverage `requestAnimationFrame` to recursively call itself. In order to keep the backdrop efficient, we'll use the targetFPS to limit the rate we actually commit to render and draw on the canvas.
// set the canvas state
let numberOfDrops = 50;
const targetFPS = 45;
let lastFrameTime = 0;
const frameDelay = 1000 / targetFPS;
function animate(time = 0) {
const delta = time - lastFrameTime;
//this allows us to limit the rate we actually draw to the canvas
if (delta >= frameDelay) {
lastFrameTime = time;
ctx.clearRect(0, 0, canvas.width, canvas.height);
//update and draw canvas elements
}
// request the next frame
requestAnimationFrame(animate);
}
// initial function call
animate()
Detect HTML Elements
To make the rain feel more grounded in the page, I wanted the drops to "splash" when they hit real HTML elements—specifically, the cards and buttons inside the parent container.
I used getBoundingClientRect() to calculate where these elements are positioned relative to the canvas. Then I update their positions on resize and scroll, storing them in a shared surfacePositions array that’s used for collision detection.
function updateElementPositions() {
const canvasRect = canvas.getBoundingClientRect();
const container = canvas.parentElement.parentElement;
container.querySelectorAll(".surface").forEach((button) => {
const rect = button.getBoundingClientRect();
surfacePositions.push({
top: rect.top - canvasRect.top,
left: rect.left - canvasRect.left,
right: rect.right - canvasRect.left,
bottom: rect.bottom - canvasRect.top,
width: rect.width,
height: rect.height,
});
});
}
Each drop checks whether its position intersects one of these rectangles using nodeCollideWithElement:
function nodeCollideWithElement(x, y){
return surfacePositions.some((pos) => {
return x < pos.right && x > pos.left && y < pos.bottom && y > pos.top;
});
}
If a collision is detected, the drop resets and a set of splash particles is spawned at the impact point. This gives a lovely tactile reaction that ties the canvas into the UI rather than floating on top of it.
Splash
When a rain drop hits an element, I wanted a little spark of feedback — something subtle but visually satisfying. I achieved this by generating a few SplashDrop particles at the point of impact. Each splash is just a small particle with basic gravity and velocity, giving it a quick arc motion before falling out of view.
class SplashDrop { x = 0; y = 0; vx = 0; vy = 0; gravity = 0.4; radius = 0.5; canRemove = false;
constructor(x, y, angle = Math.PI / 4, speed = 0.25) {
this.x = x;
this.y = y;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * -speed;
}
update(deltaTime) {
this.vy += (this.gravity * deltaTime) / 1000;
this.x += this.vx * deltaTime;
this.y += this.vy * deltaTime;
if (this.y > canvas.height) {
this.canRemove = true;
}
}
draw(ctx) {
ctx.beginPath();
ctx.fillStyle = mainFill;
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
}
Each splash drop fades naturally as it falls off screen. In the animation loop, splashes are updated and drawn just like raindrops — and removed once they're done:
splashes.forEach((splash) => { splash.update(delta);
splash.draw(ctx); });
splashes = splashes.filter((splash) => !splash.canRemove);
To create the splash, I spawn 3 particles at slightly different angles when a raindrop hits an HTML element:
if (nodeCollideWithElement(this.x, this.y)) {
splashes.push(new SplashDrop(this.x, this.y, Math.PI / 3));
splashes.push(new SplashDrop(this.x, this.y, Math.PI / 2));
splashes.push(new SplashDrop(this.x, this.y, Math.PI / 1.25));
this.reset();
}
It's a subtle but effective way to make the rain feel reactive and alive, especially when it interacts with hoverable or clickable areas in the UI.
Final Code
See the Pen Rain Canvas by Aramis Jones (@Aramis-Jones) on CodePen.