Leaf Tumble
Leaves that catch the air and stall, then slip sideways as they tip.
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.
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.