← All particles
Petal Drift
Petals fall and tumble edge-on, carried sideways by one shared current.
ambientcalmelegant26 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.
217 lines · react only
import { useEffect, useRef } from "react";
/**
* Vibary · Petal Drift
*
* Petals fall, turn edge-on as they tumble, and drift sideways on a slow
* current. The illusion depends on two things a naive particle field
* misses: each petal spins about its own axis so it periodically shows
* its edge and nearly disappears, and horizontal drift comes from a
* shared low-frequency wind rather than per-particle randomness — so the
* field moves together instead of looking like static.
*
* 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 PetalDriftProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many petals are in the air at once. */
count?: number;
/** Petal fills, sampled per petal. */
colors?: string[];
/** Fires once a petal has fallen past the bottom edge. */
onPetalLanded?: () => void;
};
type VariantConfig = {
/** Downward speed in px per second, before per-petal variation. */
fallSpeed: number;
/** Strength of the shared sideways current, in px per second. */
wind: number;
/** Turns per second about the petal's own axis. */
spin: number;
/** Petal length in px at scale 1. */
size: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely there — a few petals crossing a quiet screen.
subtle: { fallSpeed: 22, wind: 8, spin: 0.18, size: 9 },
// Reads as weather without becoming the subject. All-purpose.
default: { fallSpeed: 34, wind: 14, spin: 0.3, size: 11 },
// A gust: quicker fall, wider swing, more tumble.
playful: { fallSpeed: 52, wind: 26, spin: 0.5, size: 13 },
};
type Petal = {
x: number;
y: number;
/** Rotation about the axis running along the petal. */
flip: number;
flipSpeed: number;
/** Rotation in the plane of the screen. */
tilt: number;
tiltSpeed: number;
scale: number;
/** Phase offset into the shared wind, so they don't move in lockstep. */
phase: number;
color: string;
speed: number;
};
const DEFAULT_COLORS = ["#F6C6D0", "#F3D9E1", "#EFB8C8", "#FADDE4"];
export default function PetalDrift({
variant = "default",
count = 26,
colors = DEFAULT_COLORS,
onPetalLanded,
}: PetalDriftProps) {
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. Written in an effect, not
// during render — a ref assignment mid-render is a side effect the
// compiler is right to reject.
const landedRef = useRef(onPetalLanded);
useEffect(() => {
landedRef.current = onPetalLanded;
}, [onPetalLanded]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
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);
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const spawn = (initial: boolean): Petal => ({
x: random(-20, width + 20),
// On the first fill, scatter through the whole height so the field
// starts mid-scene rather than raining in from the top edge.
y: initial ? random(-height, height) : random(-40, -10),
flip: random(0, Math.PI * 2),
flipSpeed: config.spin * Math.PI * 2 * random(0.6, 1.4),
tilt: random(0, Math.PI * 2),
tiltSpeed: random(-0.4, 0.4),
scale: random(0.7, 1.25),
phase: random(0, Math.PI * 2),
color: colors[Math.floor(Math.random() * colors.length)],
speed: config.fallSpeed * random(0.75, 1.3),
});
let petals = Array.from({ length: count }, () => spawn(true));
const drawPetal = (petal: Petal) => {
// Flip is what sells it: the petal narrows to a line as it turns
// edge-on, which a fixed-width sprite can never do.
const edge = Math.abs(Math.cos(petal.flip));
const length = config.size * petal.scale;
const breadth = length * 0.55 * edge;
if (breadth < 0.35) return;
context.save();
context.translate(petal.x, petal.y);
context.rotate(petal.tilt);
context.fillStyle = petal.color;
context.globalAlpha = 0.55 + edge * 0.4;
context.beginPath();
context.moveTo(0, -length / 2);
context.bezierCurveTo(breadth, -length * 0.2, breadth, length * 0.25, 0, length / 2);
context.bezierCurveTo(-breadth, length * 0.25, -breadth, -length * 0.2, 0, -length / 2);
context.fill();
context.restore();
};
// Reduced motion: one still frame. The petals are decoration, so
// there is nothing to preserve except the impression of the scene.
if (reduced) {
context.clearRect(0, 0, width, height);
for (const petal of petals) drawPetal(petal);
const onResizeStill = () => {
resize();
context.clearRect(0, 0, width, height);
for (const petal of petals) drawPetal(petal);
};
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;
context.clearRect(0, 0, width, height);
// One shared current, sampled per petal at its own phase: the
// field sways as a body, which random per-petal drift never does.
const gust = Math.sin(elapsed * 0.35);
for (const petal of petals) {
petal.y += petal.speed * delta;
petal.x += (gust + Math.sin(elapsed * 0.9 + petal.phase) * 0.5) * config.wind * delta;
petal.flip += petal.flipSpeed * delta;
petal.tilt += petal.tiltSpeed * delta;
if (petal.y - config.size > height) {
landedRef.current?.();
Object.assign(petal, spawn(false));
}
if (petal.x < -40) petal.x = width + 30;
if (petal.x > width + 40) petal.x = -30;
drawPetal(petal);
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
petals = Array.from({ length: count }, () => 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" }}
/>
);
}About this effect
Ambient weather for a screen that should feel alive without asking for attention — an app-info page, a seasonal empty state, the background behind a welcome. Two details do the work: each petal spins about its own axis so it narrows to a line and nearly vanishes as it turns edge-on, and the sideways drift comes from a single low-frequency gust sampled per petal rather than independent randomness, so the field sways as a body instead of shimmering like static.
App info screenSeasonal empty stateWelcome backgroundCelebration backdrop