Snowfall Canvas

Creating an interactive snowfall animation with HTML5 Canvas that accumulates on page elements using grid-based state tracking

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 an 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;
  }

  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--;
    }
  }

  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";
    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 initializeCanvas() {
  surfacePositions.length = 0;
  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,
    });
  });

  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

Here's a live demo of the snowfall canvas in action. The snow will accumulate on elements marked with the "surface" class.

Snowfall Canvas Demo

Let it snow.

Embrace the cold

You can see the complete implementation on CodePen:

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