Rainfall Canvas

Date Added - 07/15/2025

Topics - 2d Canvas, Weather Effects, Canvas Animation

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.