Petal Shower
One fall of petals from above that thins out and settles where it lands.
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 · Petal Shower
*
* A single fall of petals from above, thickest at the start, ending in a
* scatter that stays where it landed.
*
* The technique: rotation is derived from horizontal velocity rather
* than animated on its own clock. Each petal banks into its swing —
* leaning right as it swings right, flattening at the turn — because the
* angle is read straight off the sway. A petal given an independent spin
* looks like a sprite being rotated; a petal that banks looks like it is
* being carried by air, and it costs one line.
*
* The shower ends by running out rather than by being cut off: emission
* follows a decaying envelope, so most petals leave in the first third
* of a second and the last few trail in alone.
*
* 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 PetalShowerProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Petals in the whole shower, not on screen at once. */
count?: number;
/** Petal fills, sampled per petal. */
colors?: string[];
/** Fires once the last petal has come to rest. */
onSettled?: () => void;
};
type VariantConfig = {
/** Terminal fall speed in px per second. */
fall: number;
/** Peak sideways speed of the swing, in px per second. */
sway: number;
/** Swings per second. */
swayRate: number;
/** Petal length in px at scale 1. */
size: number;
/** Radians of bank at full sideways speed. */
bank: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A handful of petals, drifting down almost straight.
subtle: { fall: 78, sway: 20, swayRate: 0.9, size: 9, bank: 0.45 },
// Reads as a celebration and is over in about two seconds.
default: { fall: 112, sway: 34, swayRate: 1.35, size: 11, bank: 0.7 },
// A thicker fall with a wider swing and a harder bank.
playful: { fall: 152, sway: 52, swayRate: 1.85, size: 12.5, bank: 0.95 },
};
type Petal = {
x: number;
y: number;
/** Where this petal comes to rest, a little above the very bottom. */
floor: number;
phase: number;
scale: number;
color: string;
speed: number;
swing: number;
/** 0 while falling, ramps to 1 once it has touched down. */
settle: number;
/** The angle it lies at once settled — near flat, never upright. */
lie: number;
/** True when it has drifted out of the frame instead of landing. */
gone: boolean;
};
const DEFAULT_COLORS = ["#F2C4CE", "#EFD9DF", "#E7AEBF", "#F8E4D9"];
export default function PetalShower({
variant = "default",
count = 48,
colors = DEFAULT_COLORS,
onSettled,
}: PetalShowerProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref, assigned in an effect
// rather than during render, so an inline arrow can't restart the fall.
const settledRef = useRef(onSettled);
useEffect(() => {
settledRef.current = onSettled;
});
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;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const 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 = (): Petal => ({
x: random(-10, width + 10),
y: random(-30, -6),
floor: height - random(2, 16),
phase: random(0, Math.PI * 2),
scale: random(0.75, 1.2),
color: colors[Math.floor(Math.random() * colors.length)],
speed: config.fall * random(0.8, 1.25),
swing: config.sway * random(0.7, 1.3),
settle: 0,
lie: (Math.random() < 0.5 ? -1 : 1) * random(1.15, 1.55),
gone: false,
});
let petals: Petal[] = [];
let spawned = 0;
/**
* Cumulative emission. A decaying envelope rather than a flat rate:
* a burst arrives as a burst, then thins, which is what makes the
* end feel like running out instead of being switched off.
*/
const emittedBy = (elapsed: number) =>
Math.min(count, Math.round(count * (1 - Math.exp(-elapsed / 0.38))));
const drawPetal = (petal: Petal, angle: number) => {
const length = config.size * petal.scale * (1 - petal.settle * 0.3);
const breadth = length * 0.42;
context.save();
context.translate(petal.x, petal.y);
context.rotate(angle);
context.fillStyle = petal.color;
context.globalAlpha = 0.92;
context.beginPath();
context.moveTo(0, -length / 2);
context.quadraticCurveTo(breadth, 0, 0, length / 2);
context.quadraticCurveTo(-breadth, 0, 0, -length / 2);
context.fill();
context.restore();
};
/** Bank angle: read off sideways speed, so the lean is the swing. */
const angleFor = (petal: Petal, vx: number) => {
const flying = Math.max(-1, Math.min(1, vx / config.sway)) * config.bank;
return flying + (petal.lie - flying) * petal.settle;
};
// Reduced motion: the shower already over, petals lying where they
// fell. It says a celebration happened, which is the whole message.
if (reduced) {
const still = () => {
context.clearRect(0, 0, width, height);
petals = Array.from({ length: count }, () => {
const petal = spawn();
petal.settle = 1;
petal.y = petal.floor - random(0, 40);
return petal;
});
for (const petal of petals) drawPetal(petal, petal.lie);
context.globalAlpha = 1;
};
still();
const onResizeStill = () => {
resize();
still();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
let announced = false;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
context.clearRect(0, 0, width, height);
const wanted = emittedBy(elapsed);
while (spawned < wanted) {
petals.push(spawn());
spawned++;
}
let resting = 0;
for (const petal of petals) {
if (petal.gone) {
resting++;
continue;
}
if (petal.settle < 1) {
const vx = Math.sin(elapsed * config.swayRate * Math.PI * 2 + petal.phase) * petal.swing;
petal.x += vx * delta;
petal.y += petal.speed * delta;
if (petal.y >= petal.floor) {
petal.y = petal.floor;
petal.settle = Math.min(1, petal.settle + delta / 0.22);
}
if (petal.x < -24 || petal.x > width + 24) {
petal.gone = true;
continue;
}
drawPetal(petal, angleFor(petal, vx));
} else {
resting++;
drawPetal(petal, petal.lie);
}
}
context.globalAlpha = 1;
if (spawned >= count && resting === petals.length) {
if (!announced) {
announced = true;
settledRef.current?.();
}
// The settled scatter is the end state, so the last frame stays
// on screen. Stopping the loop is what makes this free to leave
// mounted after it finishes.
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
const previous = height;
resize();
// Keep the petals; only move the ground they are heading for.
const shift = height - previous;
for (const petal of petals) {
petal.floor += shift;
if (petal.settle >= 1) petal.y += shift;
}
};
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
A celebration for a moment that deserves warmth rather than noise — an order confirmed, a course finished, a milestone reached. Each petal's rotation is derived from its horizontal velocity, so it banks into its swing and flattens at the turn: a petal given an independent spin looks like a rotating sprite, while one that banks looks carried by air, and the difference is a single line. The shower ends by running out rather than by being cut off, because emission follows a decaying envelope — most petals leave in the first third of a second and the last few trail in alone — and the petals that land stay, so the effect finishes in a state rather than in an empty frame.