← All particles

Leaf Tumble

Leaves that catch the air and stall, then slip sideways as they tip.

environmentalcalmfriendly22 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.

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

/**
 * Vibary · Leaf Tumble
 *
 * Leaves coming down with a real tumble — catching the air, stalling,
 * slipping sideways, catching again.
 *
 * The technique that makes the fall convincing: drag is coupled to
 * attitude. The leaf's terminal speed is recomputed each frame from how
 * much area it currently presents to the air, so broadside it stalls and
 * hangs, and as it tips toward edge-on it slips and accelerates. The
 * same tipping angle also steers it sideways, which is why a real leaf
 * zigzags — the sway and the stutter in the fall are two views of one
 * rotation, not two effects layered on top of each other. A constant
 * fall speed with a sine wobble bolted on never reads right, however
 * carefully the wobble is tuned.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `colors`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LeafTumbleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Leaves in the air at once. */
  count?: number;
  /** Leaf fills, sampled per leaf. */
  colors?: string[];
  /** Fires once a leaf has fallen past the bottom edge. */
  onLeafLanded?: () => void;
};

type VariantConfig = {
  /** Multiplier applied to `count`. */
  density: number;
  /** Stalled fall speed in px per second. Slipping is faster than this. */
  fall: number;
  /** Sideways glide in px per second at full tip. */
  swing: number;
  /** Leaf length in px at scale 1. */
  size: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A few leaves crossing a quiet screen.
  subtle: { density: 0.7, fall: 26, swing: 12, size: 11 },
  // Autumn, without becoming the subject. All-purpose.
  default: { density: 1, fall: 38, swing: 21, size: 13 },
  // A gust through a tree: faster fall, much wider glide.
  playful: { density: 1.35, fall: 54, swing: 34, size: 15 },
};

type Leaf = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Rotation about the leaf's long axis — the attitude that drives drag. */
  flip: number;
  flipSpeed: number;
  /** Rotation in the plane of the screen. */
  tilt: number;
  tiltSpeed: number;
  scale: number;
  /** Per-leaf drag, so two leaves in the same attitude still differ. */
  drag: number;
  color: string;
};

const DEFAULT_COLORS = ["#C86B3C", "#D99B45", "#A8562F", "#997A3A", "#B8462C"];

export default function LeafTumble({
  variant = "default",
  count = 22,
  colors = DEFAULT_COLORS,
  onLeafLanded,
}: LeafTumbleProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Callbacks are read through a ref so an inline arrow from the parent
  // can't restart the field on every render.
  const landedRef = useRef(onLeafLanded);
  useEffect(() => {
    landedRef.current = onLeafLanded;
  }, [onLeafLanded]);

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

    const config = VARIANTS[variant];
    const total = Math.max(3, Math.round(count * config.density));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;

    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);
    };
    // Measured before the first leaf is placed: spawning against a
    // zero-size canvas piles the whole field into one corner.
    resize();

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

    const spawn = (initial: boolean): Leaf => ({
      x: random(-20, width + 20),
      // On the first fill, scatter through the whole height so the scene
      // starts mid-fall rather than raining in from the top edge.
      y: initial ? random(-height, height) : random(-40, -12),
      vx: 0,
      vy: config.fall,
      flip: random(0, Math.PI * 2),
      flipSpeed: random(0.5, 1.5) * (Math.random() < 0.5 ? -1 : 1),
      tilt: random(0, Math.PI * 2),
      tiltSpeed: random(-0.5, 0.5),
      scale: random(0.75, 1.3),
      drag: random(0.85, 1.2),
      color: colors[Math.floor(Math.random() * colors.length)],
    });

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

    const drawLeaf = (leaf: Leaf) => {
      // The same attitude that drives the physics drives the drawing:
      // broadside the leaf shows its full width, edge-on it narrows to
      // a line.
      const broad = Math.abs(Math.cos(leaf.flip));
      const length = config.size * leaf.scale;
      const breadth = length * 0.52 * broad;
      if (breadth < 0.3) return;

      context.save();
      context.translate(leaf.x, leaf.y);
      context.rotate(leaf.tilt);
      context.fillStyle = leaf.color;
      context.globalAlpha = 0.5 + broad * 0.42;
      context.beginPath();
      context.moveTo(0, -length / 2);
      context.bezierCurveTo(breadth, -length * 0.24, breadth, length * 0.3, 0, length / 2);
      context.bezierCurveTo(-breadth, length * 0.3, -breadth, -length * 0.24, 0, -length / 2);
      context.fill();

      // A midrib, once there is enough width to hold one. It is the
      // single line that reads "leaf" rather than "petal".
      if (breadth > 1.6) {
        context.globalAlpha = 0.22 + broad * 0.16;
        context.strokeStyle = "#4A3520";
        context.lineWidth = Math.max(0.5, length * 0.035);
        context.beginPath();
        context.moveTo(0, -length * 0.44);
        context.lineTo(0, length * 0.46);
        context.stroke();
      }
      context.restore();
    };

    const drawFrame = () => {
      context.clearRect(0, 0, width, height);
      for (const leaf of leaves) drawLeaf(leaf);
      context.globalAlpha = 1;
    };

    // Reduced motion: one frame, held. Because every leaf is at its own
    // point in the tumble, the still shows the whole range — some
    // broadside and wide, some edge-on and nearly gone.
    if (reduced) {
      drawFrame();
      const onResizeStill = () => {
        resize();
        leaves = Array.from({ length: total }, () => spawn(true));
        drawFrame();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

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

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

      for (const leaf of leaves) {
        leaf.flip += leaf.flipSpeed * delta;
        const broad = Math.abs(Math.cos(leaf.flip));

        // Presented area sets the terminal speed: broadside it hangs,
        // edge-on it drops through the air it was leaning on.
        const terminal = (config.fall * (1.6 - broad * 1)) / leaf.drag;
        leaf.vy += (terminal - leaf.vy) * Math.min(1, 2.4 * delta);

        // And the direction it is tipped steers it. One rotation, two
        // symptoms — which is why the sway lines up with the stutter.
        const glide = Math.sin(leaf.flip) * config.swing;
        leaf.vx += (glide - leaf.vx) * Math.min(1, 1.8 * delta);

        leaf.x += leaf.vx * delta;
        leaf.y += leaf.vy * delta;
        leaf.tilt += leaf.tiltSpeed * delta;

        if (leaf.y - config.size > height) {
          landedRef.current?.();
          Object.assign(leaf, spawn(false));
        }
        if (leaf.x < -40) leaf.x = width + 30;
        if (leaf.x > width + 40) leaf.x = -30;
      }

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

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      leaves = Array.from({ length: total }, () => spawn(true));
    };
    window.addEventListener("resize", onResize);

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

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

About this effect

Autumn behind a screen, for a seasonal empty state, a reading app or a slow onboarding step. The fall is not steady: each leaf's terminal speed is recomputed every frame from how much area it currently presents to the air, so broadside it hangs and edge-on it slips and accelerates. The same tipping angle steers it sideways, which is why a real leaf zigzags — the sway and the stutter in the fall are two views of one rotation rather than two effects layered together, and that is what a constant fall speed with a sine wobble bolted on can never reproduce. A midrib appears once a leaf is broad enough to hold one, which is the single line that reads as leaf rather than petal.

Seasonal empty stateReading app backgroundSlow onboarding stepAutumn campaign header

Related effects