Skip to main content

Christmas Snowfall Effect

I decided to add some snowfall to my sites, and figured a light JS and some CSS would help with that, there is probably a better way to do this, but here is what I came up with.

Javascript

/* ---- Optimized Seasonal Snow Effect (Canvas) ---- */
(function() {
  // 1. Seasonal & Motion Checks
  const now = new Date();
  if (now.getMonth() !== 11) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  // 2. Configuration
  const MAX_FLAKES = 100; // Reduced slightly for safety, usually handles 500+ easily
  const SNOWFLAKES = ['\u2744', '\u2745', '\u2746']; // Unicode flakes

  // 3. Setup Canvas
  const canvas = document.createElement('canvas');
  canvas.style.position = 'fixed';
  canvas.style.top = '0';
  canvas.style.left = '0';
  canvas.style.width = '100%';
  canvas.style.height = '100%';
  canvas.style.pointerEvents = 'none'; // CRITICAL: Lets clicks pass through to site
  canvas.style.zIndex = '9999'; 
  canvas.id = 'skynet2-snow-canvas';
  document.body.appendChild(canvas);

  const ctx = canvas.getContext('2d');
  let width = window.innerWidth;
  let height = window.innerHeight;

  // Handle Resize
  function resize() {
    width = window.innerWidth;
    height = window.innerHeight;
    canvas.width = width;
    canvas.height = height;
  }
  window.addEventListener('resize', resize);
  resize(); // Init size

  // 4. Particle System
  const flakes = [];

  function createFlake() {
    return {
      x: Math.random() * width,
      y: Math.random() * height - height, // Start above screen
      r: 10 + Math.random() * 15,         // Size
      d: Math.random() * MAX_FLAKES,      // Density/Seed for sway
      s: 1 + Math.random() * 2,           // Fall speed
      txt: SNOWFLAKES[Math.floor(Math.random() * SNOWFLAKES.length)]
    };
  }

  // Populate initial array
  for (let i = 0; i < MAX_FLAKES; i++) {
    flakes.push(createFlake());
  }

  // 5. The Render Loop
  let animationFrameId;

  function draw() {
    ctx.clearRect(0, 0, width, height); // Clear previous frame

    // Style
    ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
    ctx.font = "20px sans-serif"; // Base font size

    for (let i = 0; i < MAX_FLAKES; i++) {
      const f = flakes[i];

      // Update positions
      f.y += f.s;
      f.x += Math.sin(f.d) * 0.5; // Sway math
      f.d += 0.01; // Increment sway angle

      // Draw
      ctx.font = `${f.r}px sans-serif`;
      ctx.fillText(f.txt, f.x, f.y);

      // Loop: If it falls off the bottom, send to top
      if (f.y > height) {
        flakes[i] = createFlake();
        flakes[i].y = -20; // Reset to just above top
      }
    }

    animationFrameId = requestAnimationFrame(draw);
  }

  // Start
  draw();
})();

CSS

/* === SKYNET2 winter snow effect ====================== */
#skynet2-snow {
  position: fixed;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 9999;
}

.skynet2-snowflake {
  position: absolute;
  top: -2rem;
  color: rgba(255, 255, 255, 0.9);
  font-size: 0.8rem;
  user-select: none;
  opacity: 0;
  /* Names must match the JS animation string */
  animation-name: skynet2-snow-fall, skynet2-snow-sway;
  animation-timing-function: linear, ease-in-out;
  animation-iteration-count: 1, infinite;
}

/* Vertical Fall (GPU accelerated) */
@keyframes skynet2-snow-fall {
  0% {
    transform: translate3d(0, -10vh, 0);
    opacity: 0;
  }
  10% {
    opacity: 1;
  }
  100% {
    transform: translate3d(0, 110vh, 0);
    opacity: 0;
  }
}

/* Horizontal Sway (Margin-based to avoid conflict) */
/* Logic: Center -> Right -> Left -> Center */
@keyframes skynet2-snow-sway {
  0% {
    margin-left: 0;
  }
  25% {
    margin-left: 20px; 
  }
  75% {
    margin-left: -20px;
  }
  100% {
    margin-left: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  #skynet2-snow {
    display: none !important;
  }
}