← All particles

Petal Shower

One fall of petals from above that thins out and settles where it lands.

celebrationfriendlyelegant48 particles · light · canvas-2d · automatic · finite
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.

274 lines · react only
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.

Order confirmedCourse completedMilestone reachedWarm success screen

Related effects