Skip to content
bgHub
Gallery

Mono Ice Falling Leaves

Canvas 2DLight

busy falling leaves drifting down on a sine of their own sway. Canvas 2D.

#falling#nature#canvas#leaves#mono-ice
Live preview
1 file
// @ts-nocheck
import { useEffect, useRef } from "react";

// Render <Petals /> inside a container with position: relative.
const opts = {"variant":"leaves","bg":"#06080c","colors":["#e2e8f0","#94a3b8","#cbd5e1"],"light":{"bg":"#f5f7fa","colors":["#334155","#64748b","#475569"]},"speed":1.35,"density":1.4};

export function Petals() {
  const ref = useRef(null);
  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
const ctx = canvas.getContext("2d");
/* Both schemes ship in opts; `light` is absent in the live preview, which
   resolves the scheme itself, so nothing is observed there. */
const schemeMq =
  opts.light && typeof matchMedia === "function"
    ? matchMedia("(prefers-color-scheme: light)")
    : null;
const schemeNow = () => (schemeMq && schemeMq.matches ? opts.light : opts);
/** The scheme the scene or particle set was first built from. */
const SCHEME_BUILT = schemeNow();
let COLORS = SCHEME_BUILT.colors, BG = SCHEME_BUILT.bg;
const onScheme = () => {
  const s = schemeNow();
  COLORS = s.colors; BG = s.bg;
  if (typeof seed === "function") seed();
  if (typeof applyScheme === "function") applyScheme();
};
if (schemeMq) schemeMq.addEventListener("change", onScheme);
const SPEED = opts.speed || 1, DENSITY = opts.density || 1, V = opts.variant;
let w = 0, h = 0, dpr = 1, raf = 0, t = 0;
const mouse = { x: -9999, y: -9999, on: false };
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[(Math.random() * a.length) | 0];
const TAU = Math.PI * 2;
const TUNE = {
  sakura:   { fall: 0.9, sway: 1.1, size: [5, 10], spin: 0.03, alpha: 0.9 },
  leaves:   { fall: 1.2, sway: 0.8, size: [7, 14], spin: 0.05, alpha: 0.92 },
  feathers: { fall: 0.55, sway: 1.5, size: [6, 13], spin: 0.02, alpha: 0.8 },
  ash:      { fall: 0.7, sway: 0.7, size: [2.5, 6], spin: 0.06, alpha: 0.85 },
};
const K = TUNE[V] || TUNE.sakura;
let ps = [];

function make(above) {
  return {
    x: rnd(-40, w + 40),
    y: above ? rnd(-h, 0) : rnd(-60, -10),
    s: rnd(K.size[0], K.size[1]),
    ph: rnd(0, TAU),
    rot: rnd(0, TAU),
    spin: rnd(-K.spin, K.spin),
    vy: rnd(0.5, 1.3) * K.fall,
    c: pick(COLORS),
    a: rnd(0.55, 1) * K.alpha,
  };
}

function seed() {
  const n = Math.max(26, Math.min(190, Math.floor((w * h) / 11000 * DENSITY)));
  ps = Array.from({ length: n }, () => make(true));
}

function shape(p) {
  ctx.save();
  ctx.translate(p.x, p.y);
  ctx.rotate(p.rot);
  ctx.fillStyle = p.c;
  ctx.globalAlpha = p.a;
  if (V === "ash") {
    // Ash flakes are small enough to disappear on a card, so they carry their
    // own glow rather than relying on size.
    ctx.shadowBlur = 8; ctx.shadowColor = p.c;
    ctx.fillRect(-p.s * 0.5, -p.s * 0.5, p.s, p.s);
    ctx.shadowBlur = 0;
  } else if (V === "leaves") {
    ctx.beginPath();
    ctx.moveTo(0, -p.s);
    ctx.quadraticCurveTo(p.s * 0.8, 0, 0, p.s);
    ctx.quadraticCurveTo(-p.s * 0.8, 0, 0, -p.s);
    ctx.fill();
    ctx.globalAlpha = p.a * 0.5;
    ctx.strokeStyle = BG; ctx.lineWidth = 0.8;
    ctx.beginPath(); ctx.moveTo(0, -p.s); ctx.lineTo(0, p.s); ctx.stroke();
  } else if (V === "feathers") {
    ctx.beginPath();
    ctx.ellipse(0, 0, p.s * 0.32, p.s, 0, 0, TAU);
    ctx.fill();
    ctx.globalAlpha = p.a * 0.45;
    ctx.strokeStyle = BG; ctx.lineWidth = 0.7;
    ctx.beginPath(); ctx.moveTo(0, -p.s); ctx.lineTo(0, p.s); ctx.stroke();
  } else {
    ctx.beginPath();
    ctx.ellipse(0, 0, p.s * 0.55, p.s * 0.85, 0, 0, TAU);
    ctx.fill();
  }
  ctx.restore();
}

function draw() {
  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, w, h);
  for (const p of ps) {
    p.ph += 0.02 * SPEED;
    p.y += p.vy * SPEED;
    p.x += Math.sin(p.ph) * K.sway * SPEED;
    p.rot += p.spin * SPEED;
    if (mouse.on) {
      const dx = p.x - mouse.x, dy = p.y - mouse.y, d = Math.hypot(dx, dy) + 1;
      if (d < 120) { p.x += (dx / d) * 1.6; p.y += (dy / d) * 0.8; }
    }
    if (p.y > h + 30) Object.assign(p, make(false));
    if (p.x < -60) p.x = w + 50;
    if (p.x > w + 60) p.x = -50;
    shape(p);
  }
  ctx.globalAlpha = 1;
}
function resize() {
  w = canvas.clientWidth; h = canvas.clientHeight;
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = w * dpr; canvas.height = h * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  seed();
}
const onMove = (e) => { const r = canvas.getBoundingClientRect(); mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; mouse.on = true; };
const onLeave = () => { mouse.on = false; };
const loop = () => { t += 0.016 * SPEED; draw(); raf = requestAnimationFrame(loop); };
const ro = new ResizeObserver(resize); ro.observe(canvas);
resize(); loop();
canvas.addEventListener("pointermove", onMove); canvas.addEventListener("pointerleave", onLeave);
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener("pointermove", onMove); canvas.removeEventListener("pointerleave", onLeave); if (schemeMq) schemeMq.removeEventListener("change", onScheme); };
  }, []);
  return (
    <canvas ref={ref} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }} />
  );
}

Copy or download any file. The HTML build loads Three.js from a CDN via an import map, so it runs by just opening index.html. The React build is react-three-fiber; setup notes live at the top of each file.

Variants

40 of this pattern · page 1 of 4