← All particles

Snow Settle

Snow that falls and accumulates, building an uneven drift along the bottom edge.

ambientcalmcozyquiet90 particles · light · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

331 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Snow Settle
 *
 * Snow that falls and then stays: the bottom edge is a heightmap of
 * columns, and every flake that lands writes into the column it hit
 * plus a little into its neighbours. Two things follow from that and
 * neither needs a noise function. The drift piles unevenly because the
 * same shared wind that sways the flakes decides which columns they
 * reach; and because each column also creeps toward the average of its
 * neighbours, a spike slumps into a bank instead of standing up like a
 * bar chart.
 *
 * Once the drift holds its volume it compacts rather than growing, so
 * fresh snow keeps roughening a bank that has stopped rising. Capping
 * each column instead would fill every one to the ceiling and the
 * slump would then iron the whole thing flat.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SnowSettleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Flakes in the air at once. Overrides the variant's density. */
  count?: number;
  /**
   * Flake and drift color. A cool pale blue rather than white, so the
   * snow still reads against a light surface.
   */
  color?: string;
  /** Fires once when the drift first reaches its volume, and stops growing. */
  onDriftFull?: () => void;
};

type VariantConfig = {
  /** Flakes in the air at this setting. */
  count: number;
  /** Fall speed in px per second, before per-flake variation. */
  fall: number;
  /** Strength of the shared sideways wind, in px per second. */
  wind: number;
  /** Largest flake radius in px. */
  size: number;
  /**
   * Mean depth the drift settles at, as a fraction of the height.
   * Individual banks run above it — this is a volume, not a ceiling.
   */
  cap: number;
  /** How fast a column creeps toward its neighbours, per second. */
  slump: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Still air, a thin rim of snow on the sill.
  subtle: { count: 60, fall: 20, wind: 4, size: 1.9, cap: 0.12, slump: 0.6 },
  // Steady snowfall building a shallow bank. All-purpose.
  default: { count: 90, fall: 32, wind: 10, size: 2.3, cap: 0.18, slump: 0.8 },
  // Weather: quicker fall, a wind that leans the drift to one side.
  playful: { count: 130, fall: 48, wind: 20, size: 2.7, cap: 0.24, slump: 1 },
};

type Flake = {
  x: number;
  y: number;
  radius: number;
  speed: number;
  /** Phase into the shared wind, so the field sways as a body. */
  phase: number;
  alpha: number;
};

/** Roughly the width of one heightmap column, in px. */
const COLUMN_WIDTH = 7;

export default function SnowSettle({
  variant = "default",
  count,
  color = "#BAD0E6",
  onDriftFull,
}: SnowSettleProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The callback is held in a ref, and the ref is updated in its own
  // effect rather than during render: an inline arrow from the parent
  // then changes identity every render without restarting the snowfall.
  const fullRef = useRef(onDriftFull);
  useEffect(() => {
    fullRef.current = onDriftFull;
  }, [onDriftFull]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const total = count ?? config.count;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;
    let columns: number[] = [];
    let columnWidth = COLUMN_WIDTH;
    let cap = 0;
    let announced = false;

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    /**
     * A shallow uneven bank to start from, so the scene never opens on a
     * bare edge. Two sines at unrelated frequencies read as a natural
     * profile; one sine reads as a wave.
     */
    const seedPile = (columnCount: number) => {
      const offset = random(0, Math.PI * 2);
      return Array.from({ length: columnCount }, (_, index) => {
        const t = index / columnCount;
        const shape =
          Math.sin(t * 5.3 + offset) * 0.5 + Math.sin(t * 11.7 + offset * 1.7) * 0.25;
        return cap * (0.16 + Math.max(0, shape) * 0.2);
      });
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);

      const columnCount = Math.max(8, Math.round(width / COLUMN_WIDTH));
      columnWidth = width / columnCount;
      cap = height * config.cap;
      if (columns.length === 0) {
        columns = seedPile(columnCount);
      } else {
        // Resample the existing profile rather than dropping it: the
        // drift someone has been watching should survive a resize.
        const previous = columns;
        columns = Array.from({ length: columnCount }, (_, index) => {
          const source = (index / (columnCount - 1 || 1)) * (previous.length - 1);
          const low = Math.floor(source);
          const high = Math.min(previous.length - 1, low + 1);
          const t = source - low;
          return Math.min(cap, previous[low] + (previous[high] - previous[low]) * t);
        });
      }
    };
    resize();

    const spawn = (initial: boolean): Flake => ({
      x: random(-10, width + 10),
      y: initial ? random(-20, height * 0.85) : random(-30, -6),
      radius: random(0.9, config.size),
      speed: config.fall * random(0.7, 1.35),
      phase: random(0, Math.PI * 2),
      alpha: random(0.55, 1),
    });

    let flakes = Array.from({ length: total }, () => spawn(true));

    /** Depth of the drift under an x position, interpolated between columns. */
    const depthAt = (x: number) => {
      const source = x / columnWidth - 0.5;
      const low = Math.max(0, Math.min(columns.length - 1, Math.floor(source)));
      const high = Math.max(0, Math.min(columns.length - 1, low + 1));
      const t = Math.max(0, Math.min(1, source - low));
      return columns[low] + (columns[high] - columns[low]) * t;
    };

    /** Write a landed flake into the heightmap, spread over three columns. */
    const deposit = (x: number, radius: number) => {
      const index = Math.max(
        0,
        Math.min(columns.length - 1, Math.round(x / columnWidth - 0.5))
      );
      const gain = radius * 3;
      const spread: [number, number][] = [
        [index - 1, 0.25],
        [index, 0.5],
        [index + 1, 0.25],
      ];
      for (const [target, share] of spread) {
        if (target < 0 || target >= columns.length) continue;
        // A generous per-column ceiling only, so one lucky column cannot
        // grow a tower between compactions. The real limit is volume.
        columns[target] = Math.min(cap * 1.8, columns[target] + gain * share);
      }
    };

    /**
     * Compaction, and the reason the drift keeps its shape. Capping each
     * column instead would let every column reach the ceiling and the
     * bank would flatten into a bar — worse, the slump keeps smoothing,
     * so it would end perfectly level. Holding the total volume and
     * pressing the whole profile down proportionally means new snow
     * keeps roughening a drift that is no longer growing.
     */
    const compact = () => {
      let volume = 0;
      for (const value of columns) volume += value;
      const budget = cap * columns.length;
      if (volume <= budget) return;
      const squeeze = budget / volume;
      for (let index = 0; index < columns.length; index++) columns[index] *= squeeze;
      if (!announced) {
        announced = true;
        fullRef.current?.();
      }
    };

    const drawDrift = () => {
      context.beginPath();
      context.moveTo(-2, height + 2);
      context.lineTo(-2, height - columns[0]);
      // Midpoint smoothing: the curve passes between column tops rather
      // than through them, which is what turns a staircase into a bank.
      for (let index = 0; index < columns.length - 1; index++) {
        const x = (index + 0.5) * columnWidth;
        const y = height - columns[index];
        const nextX = (index + 1.5) * columnWidth;
        const nextY = height - columns[index + 1];
        context.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
      }
      context.lineTo(width + 2, height - columns[columns.length - 1]);
      context.lineTo(width + 2, height + 2);
      context.closePath();
      context.fillStyle = color;
      context.globalAlpha = 0.92;
      context.fill();
      context.globalAlpha = 1;
    };

    const render = () => {
      context.clearRect(0, 0, width, height);
      drawDrift();
      context.fillStyle = color;
      for (const flake of flakes) {
        context.globalAlpha = flake.alpha;
        context.beginPath();
        context.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the drift as it would look after a while, with
    // flakes held in the air. The state of the scene is the content;
    // the falling is only how it got there.
    if (reduced) {
      columns = columns.map((value) => Math.min(cap, value * 2.6));
      render();
      const onResizeStill = () => {
        resize();
        flakes = Array.from({ length: total }, () => spawn(true));
        render();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;

      // One shared current, sampled per flake at its own phase.
      const gust = Math.sin(elapsed * 0.28) + Math.sin(elapsed * 0.11) * 0.5;

      for (const flake of flakes) {
        flake.y += flake.speed * delta;
        flake.x +=
          (gust + Math.sin(elapsed * 0.8 + flake.phase) * 0.35) * config.wind * delta;

        if (flake.y >= height - depthAt(flake.x) - flake.radius * 0.5) {
          deposit(flake.x, flake.radius);
          Object.assign(flake, spawn(false));
          continue;
        }
        if (flake.x < -12) flake.x = width + 8;
        if (flake.x > width + 12) flake.x = -8;
      }

      // The slump. Without it the heightmap grows spikes exactly where
      // the wind happens to hold steady, and snow does not do that.
      const relaxed = columns.slice();
      for (let index = 0; index < columns.length; index++) {
        const left = columns[Math.max(0, index - 1)];
        const right = columns[Math.min(columns.length - 1, index + 1)];
        relaxed[index] +=
          ((left + right) / 2 - columns[index]) * Math.min(1, config.slump * delta);
      }
      columns = relaxed;
      compact();

      render();
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => resize();
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, color]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

Most snow effects are rain with white dots; this one has somewhere to land. The bottom edge is a heightmap of narrow columns, and every flake that reaches it writes into the column it hit plus a share into each neighbour. The drift comes out uneven for free — the same shared wind that sways the flakes decides which columns they reach — and because each column also creeps toward the average of its neighbours, a spike slumps into a bank rather than standing up like a bar chart. Once it holds its volume the drift compacts instead of growing, so fresh snow keeps roughening a bank that has stopped rising — capping each column instead would fill them all to the ceiling and the slump would iron the drift flat. It survives a resize by resampling the profile, and settles at a mean depth well under a fifth of the container.

Seasonal empty stateQuiet hours noticeWinter release noteOffline screen

Related effects