Snowfall Canvas

Date Added - 08/05/2025

Topics - 2d Canvas, Weather Effects, Canvas Animation, Element Interaction

Let it snow.

Embrace the cold

Introduction

This canvas demo shows dynamic snowflakes that pile up at the bottom of the screen or on real HTML elements. It's built entirely with low-level <canvas> APIs, and features efficient grid-based state tracking, natural motion, and interaction with DOM surfaces.

Approach the problem

Instead of having snowflakes disappear when they fall off screen, we wanted them to accumulate naturally — either at the base of the canvas or on top of UI elements.


This requires:

  • Simulating physical pile-up behavior
  • Tracking occupied canvas grid cells
  • Detecting collisions with DOM elements using getBoundingClientRect()

We’ll walk through how to initialize the canvas, build the grid system, update snowflakes, and maintain frame-rate stability.

Snowflake

  • Snowflake: A falling particle with wave motion
  • The Snowflake should detect when it collides with a screen element OR a snow pile and "land".
  • The Snowflake should help snow spread by randomly moving 1 column left or right on contact.
    
        
  class Snowflake {
      x = 0;
      y = 0;
      angle = Math.PI / 2 + Math.PI / 12
      velocity = 4;
      radius = 1;
      hit = false;
      amplitude = Math.random() * 20 + 10; // wave width
      frequency = Math.random() * 0.02 + 0.01; // wave frequency
      phase = Math.random() * Math.PI * 2; // random phase offset
      baseX = 0; // starting x position

      constructor(x, y, angle = Math.PI / 2 + Math.PI / 12) {
          this.x = x;
          this.y = y;
          this.angle = angle;
          this.baseX = x;
      }

      reset() {
          this.y = -10;
          this.x = Math.random() * canvas.width;
          this.hit = true;
      }

      update() {
          this.y += Math.sin(this.angle) * this.velocity;
          this.x = this.baseX + Math.sin(this.y * this.frequency + this.phase) * this.amplitude;

          const [col] = pile.getCoords(this.x, this.y);

          if (
              nodeCollideWithSnowPile(this.x, this.y) ||
              nodeCollideWithElement(this.x, this.y)
          ) {
              const surfaceY = surfaceMap[col]; // top of surface or canvas bottom
              const baseRow = pile.getCoords(this.x, surfaceY)[1];

              // Randomly choose left (-1), center (0), or right (+1)
              let offset = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
              let targetCol = Math.max(0, Math.min(col + offset, pile.piles.length - 1));

              pile.add( targetCol, baseRow);
              this.reset();
          } else if (this.y > canvas.height) {
              const baseRow = pile.getCoords(this.x, canvas.height)[1];
              pile.add( col, baseRow);
              this.reset();
          } else {
              this.hit = false;
          }

      }

      draw(ctx) {
      ctx.beginPath();
      ctx.fillStyle = "white"
      ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
      ctx.fill();
      }
  }
    

Snow Pile

  • Snow Pile: An array of small columns of snow. The screen is populated on initialization with an array of snow columns.
  • The grid uses a Uint8Array for performance, and tracks 0 (empty), 1 (snow), or 2 (obstacle).
  • It should combat the tendency for the columns to be tall and narrow by looking at its neighbors and randomly 'spreading' by knocking the top flake off and to the left or right.
  • The column should animate snow cells falling if there is en empty cell under it.
    
        
class SnowPile {
    cellWidth;
    // 0 = empty, 1 = snow 2 = blocker
    piles = new Uint8Array(0);
    colCount = 0;
    rowCount = 0;

    constructor(width, height) {
        this.cellWidth = squareLength;

        this.resize(width, height)
    }


    // get the flat index from the col row
    getIndex(col, row) {
        return row * this.colCount + col;
    }

    getValue(col, row) {
        if (col < 0 || col >= this.colCount || row < 0 || row >= this.rowCount) return undefined;
        return this.piles[this.getIndex(col, row)];
    }

    setValue(col, row, value) {
        if (
        col < 0 || col >= this.colCount ||
        row < 0 || row >= this.rowCount
        ) return;

        this.piles[this.getIndex(col, row)] = value;
    }

    isInBounds(col, row) {
        return (
        col >= 0 && col < this.colCount &&
        row >= 0 && row < this.rowCount
        );
    }

    resize(width, height) {
        this.colCount = Math.ceil(canvas.width / this.cellWidth)
        this.rowCount = Math.ceil(canvas.height / this.cellWidth)
        this.piles = new Uint8Array(this.colCount * this.rowCount);


        for (let i = 0; i < this.colCount; i++) {
            for (let j = 0; j < this.rowCount; j++) {
                // Default to empty
                this.setValue(i, j, 0)
            }
        }

        for (let col = 0; col < this.colCount; col++) {
            for (let row = 0; row < this.rowCount; row++) {
                // Calculate cell center
                const x = col * this.cellWidth + this.cellWidth / 2;
                const y = row * this.cellWidth + this.cellWidth / 2;
                // Check if inside any surface
                for (const pos of surfacePositions) {
                    if (
                        x >= pos.left &&
                        x <= pos.right &&
                        y >= pos.top &&
                        y <= pos.bottom
                    ) {
                        this.setValue(col, row, 2)
                        break;
                    }
            }
        }
        }
    }

    getCoords(x, y) {
        const xCol = Math.floor(x / this.cellWidth);
        const yRow = Math.floor(y / this.cellWidth);

        // Clamp to valid grid indices
        const col = Math.max(0, Math.min(xCol, this.colCount - 1));
        const row = Math.max(0, Math.min(yRow, this.rowCount - 1));

        return [col, row];
    }

    add(col, baseRow) {
        const rowCount = this.rowCount;

        if (!collectAtTheBottom && baseRow === rowCount - 1) {
            return;
        }

        let row = Math.min(baseRow, rowCount - 1);

        while (row >= 0) {
            if (this.getValue(col, row) === 0) {
                this.setValue(col, row, 1);
                return;
            }
            row--;
        }

        // If it gets here, column is full — do nothing or reset if you want overflow behavior
    }

    isCellFull(col, row){
        return this.getValue(col, row) && !this.getValue(col, row-1);
    }

    clearRow(rowIndex) {
        for (let col = 0; col < this.colCount; col++) {
        this.setValue(col, rowIndex, 0);
        }
    }


    update() {
        const colCount = this.colCount;
        const rowCount = this.rowCount;

        // From bottom-2 upward
        for (let row = rowCount - 2; row >= 0; row--) {
            // Skip if this row is full
            if (this.isRowFull(row)) continue;

            for (let col = 0; col < colCount; col++) {
                const cell = this.getValue(col, row);

                if (cell === 1) {
                    // Check the cell below
                    if (this.getValue(col, row + 1) === 0) {
                        
                        this.setValue(col, row + 1, 1)
                        this.setValue(col, row, 0)
                    } else if (this.getValue(col, row + 1) === 2) {
                        // resting on surface, do nothing
                        continue;
                    } else {
                        // maybe spread
                        let columnSnowCount = 0;
                        for (let r = 0; r < rowCount; r++) {
                            if (this.getValue(col, r) === 1) columnSnowCount++;
                        }
                        const willSpread = Math.random() < chanceToSpread * columnSnowCount;

                        if (willSpread && columnSnowCount < rowCount - 3) {
                            const directions = [];

                            const canMoveLeft = col > 0 && this.getValue(col - 1, row) === 0;
                            const canMoveRight = col < colCount - 1 && this.getValue(col + 1, row) === 0;

                            if (canMoveLeft) directions.push(-1);
                            if (canMoveRight) directions.push(1);

                            if (directions.length > 0) {
                            const dir = directions[Math.floor(Math.random() * directions.length)];
                            this.setValue(col + dir, row, 1)
                            this.setValue(col, row, 0)
                            }
                        } else {
                            const willDecay = Math.random() < chanceToDecay;
                            if (willDecay) {
                            this.setValue(col, row, 0)
                            }
                        }
                    }
                }
            }
        }

        if (!collectAtTheBottom) {
            this.clearRow(this.rowCount - 1)
        }
        }
        
    isRowFull(rowIndex) {
        for (let i = 0; i < this.colCount; i++) {
        if (this.getValue(i, rowIndex) !== 1) return false;
        }
        return true;
    }

    drawRow(rowIndex = 0) {
        ctx.fillStyle = "white"
        ctx.fillRect(0, rowIndex * this.cellWidth, this.colCount * this.cellWidth, this.cellWidth)
    }

    drawChunk(rowIndex, colIndex, type = 1,ctx) {
        let newIndex = colIndex;
        const colCount = this.colCount;
        // scan down row until empty cell is found, then draw starting from rowIndex to empty, if we reach the end cutoff and draw to the end, return the new colIndex
        while (
        newIndex < colCount &&
        this.getValue(newIndex, rowIndex) === type
        ) {
        newIndex++;
        }

        ctx.fillStyle = debug ? colIndex % 2 === 0 ? "green" : "red" : "white"; // alternate white/gray
        ctx.fillRect(
        colIndex * this.cellWidth,
        rowIndex * this.cellWidth,
        (newIndex-colIndex) * this.cellWidth,
        this.cellWidth
        )

        return newIndex;
    }

    draw(ctx) {
        const rowCount = this.rowCount;
        const colCount = this.colCount;
        
        for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
            ctx.fillStyle = "white";
            //Check Row
            if (this.isRowFull(rowIndex)) {
            this.drawRow(rowIndex);
            continue;
            } else {
            for (let colIndex = 0; colIndex < colCount; colIndex++) {
                if (this.getValue(colIndex, rowIndex) === 1) {
                ctx.fillStyle = "white";
                colIndex = this.drawChunk(rowIndex, colIndex, 1, ctx) - 1
                } else if (debug === true && this.getValue(colIndex, rowIndex) === 2) {
                ctx.fillStyle = "blue";
                colIndex = this.drawChunk(rowIndex, colIndex, 2, ctx) - 1
                }
            }
            }
        }
    }
}
    

Element & Surface Collision

Snowflakes pile up either at the bottom of the canvas or on HTML elements (like buttons or cards) marked with .surface.

To detect them, we update a surfacePositions array using getBoundingClientRect():

    
        
function updateElementPositions() {
  const canvasRect = canvas.getBoundingClientRect();
  surfacePositions.length = 0;

  document.querySelectorAll('.surface').forEach(surface => {
    const rect = surface.getBoundingClientRect();
    surfacePositions.push({
      top: rect.top - canvasRect.top,
      left: rect.left - canvasRect.left,
      right: rect.right - canvasRect.left,
      bottom: rect.bottom - canvasRect.top,
    });
  });

  calculateSurfaceMap();
}

    

These are compared against falling snowflake positions every frame. When a collision is detected, the flake is added to the SnowPile grid.

Animation Loop

The snow animation uses a frame-throttled loop to balance visual smoothness with CPU efficiency.

    
        
let lastFrameTime = 0;
const frameDelay = 1000 / 45;

function animate(time = 0) {
  const delta = time - lastFrameTime;
  if (delta >= frameDelay) {
    lastFrameTime = time;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    pile.update();
    pile.draw(ctx);
    snowflakes.forEach(flake => {
      flake.update();
      flake.draw(ctx);
    });
  }
  requestAnimationFrame(animate);
}
animate();

    

Final Code

See the Pen Snow Canvas by Aramis Jones (@Aramis-Jones) on CodePen.