← 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

Related effects