Animation
CanvasPetalField
Canvas of marigold and jasmine petals drifting down, nudged by the cursor.
npx bezel-add add canvas-petal-fieldAbout
A falling petals animation for React drawn on a canvas: marigold and jasmine shapes drift down, sway and rotate, and a mouse "gust" nudges them sideways. The petals are drawn with canvas paths, so there are no image assets to load. It can be limited to the lower part of its area, and nothing is drawn under prefers-reduced-motion.
Key props: count (number of petals, default 16), heightFraction (fraction of the canvas, from the bottom, that petals use), className (positioning classes for the canvas)
packages/ui/src/animation/CanvasPetalField.tsx · 134 lines
"use client";
import { useEffect, useRef } from "react";
type CanvasPetalFieldProps = {
count?: number;
className?: string;
/** Restrict drawing to bottom fraction of the canvas (0–1). Default 1 = full. */
heightFraction?: number;
};
type Petal = {
x: number;
y: number;
r: number;
rot: number;
vr: number;
speed: number;
sway: number;
swaySpeed: number;
alpha: number;
kind: 0 | 1;
};
/**
* Ambient marigold / jasmine petals drawn as canvas shapes with a faint
* mouse "gust" that nudges them sideways. No image assets.
*/
export function CanvasPetalField({
count = 16,
className = "pointer-events-none absolute inset-0 z-30 h-full w-full",
heightFraction = 1,
}: CanvasPetalFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let W = 0;
let H = 0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resize = () => {
const r = canvas.getBoundingClientRect();
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
const petals: Petal[] = Array.from({ length: count }, () => ({
x: Math.random() * W,
y: Math.random() * H - H,
r: 6 + Math.random() * 9,
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.02,
speed: 0.25 + Math.random() * 0.5,
sway: Math.random() * 26,
swaySpeed: 0.01 + Math.random() * 0.02,
alpha: 0.12 + Math.random() * 0.22,
kind: Math.random() > 0.5 ? 0 : 1,
}));
let gust = 0;
const onMove = (e: MouseEvent) => {
gust = (e.clientX / window.innerWidth - 0.5) * 0.8;
};
window.addEventListener("mousemove", onMove);
const drawMarigold = (r: number) => {
ctx.fillStyle = "#d98c1f";
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
ctx.beginPath();
ctx.ellipse(Math.cos(a) * r * 0.45, Math.sin(a) * r * 0.45, r * 0.4, r * 0.26, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "#b8731a";
ctx.beginPath();
ctx.arc(0, 0, r * 0.3, 0, Math.PI * 2);
ctx.fill();
};
const drawJasmine = (r: number) => {
ctx.fillStyle = "#f3ead9";
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
ctx.beginPath();
ctx.ellipse(Math.cos(a) * r * 0.4, Math.sin(a) * r * 0.4, r * 0.34, r * 0.22, a, 0, Math.PI * 2);
ctx.fill();
}
};
let t = 0;
let raf = 0;
const loop = () => {
ctx.clearRect(0, 0, W, H);
t++;
const y0 = H * (1 - heightFraction);
for (const p of petals) {
p.y += p.speed;
p.x += Math.sin(t * p.swaySpeed) * 0.3 + gust;
p.rot += p.vr;
if (p.y > H + 30) {
p.y = y0 - 30;
p.x = Math.random() * W;
}
if (p.y < y0 - 40) continue;
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
if (p.kind === 0) drawMarigold(p.r);
else drawJasmine(p.r);
ctx.restore();
}
raf = requestAnimationFrame(loop);
};
loop();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
};
}, [count, heightFraction]);
return <canvas ref={canvasRef} aria-hidden className={className} />;
}