← All particles

Firefly Field

A few points drifting in the dark, each flashing on its own rhythm.

ambientcalmfriendly14 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.

233 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Firefly Field
 *
 * A few points that drift and flash, each on its own rhythm.
 *
 * The technique: the flash envelope is asymmetric — up in about a tenth
 * of a second, down over most of a second — and it sits inside a long
 * dark gap. A sine wave on opacity gives you dots that pulse; the fast
 * rise and slow decay is what says a light was switched on and is now
 * dying away, and the two seconds of nothing in between is what makes a
 * flash an event rather than a rhythm. An unlit fly is not drawn at all.
 *
 * The drift matters too, and the cheap version gets it wrong: the random
 * walk is applied to each fly's heading, never to its position. Jittering
 * a position looks like noise; letting a heading wander produces long,
 * smooth, aimless curves — which is what flight looks like.
 *
 * Self-contained: one canvas plus a glow sprite it draws once.
 * Works with zero props; tune via `count`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FireflyFieldProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How many flies. Deliberately few — a swarm is a different effect. */
  count?: number;
  /** Flash color — this one wants a dark surface behind it. */
  color?: string;
};

type VariantConfig = {
  /** Flight speed in px per second. */
  drift: number;
  /** Seconds between one fly's flashes, before per-fly variation. */
  period: number;
  /** Glow radius in px at full brightness. */
  glow: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Long gaps and slow drift: minutes could pass and nothing demands you.
  subtle: { drift: 5, period: 4.6, glow: 9 },
  // A flash every few seconds somewhere in the frame. All-purpose.
  default: { drift: 9, period: 3.4, glow: 11 },
  // Livelier flight and a shorter gap, still nowhere near a strobe.
  playful: { drift: 15, period: 2.5, glow: 13 },
};

/** Seconds to full brightness. Short, but never instant. */
const RISE = 0.12;
/** Seconds of afterglow. Six times the rise — that ratio is the effect. */
const DECAY = 0.72;

type Fly = {
  x: number;
  y: number;
  heading: number;
  speed: number;
  period: number;
  /** Seconds into its own cycle at time zero. */
  offset: number;
  scale: number;
};

/** `#RRGGBB` plus an alpha, since gradient stops need rgba. */
function withAlpha(hex: string, alpha: number) {
  const value = hex.replace("#", "");
  const r = parseInt(value.slice(0, 2), 16);
  const g = parseInt(value.slice(2, 4), 16);
  const b = parseInt(value.slice(4, 6), 16);
  return `rgba(${r},${g},${b},${alpha})`;
}

export default function FireflyField({
  variant = "default",
  count = 14,
  color = "#DCE79B",
}: FireflyFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  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;

    // One glow, drawn once. A tight bright core inside a wide soft halo
    // is what reads as a light source rather than a coloured circle.
    const sprite = document.createElement("canvas");
    const spriteSize = 48;
    sprite.width = spriteSize;
    sprite.height = spriteSize;
    const spriteContext = sprite.getContext("2d");
    if (!spriteContext) return;
    const half = spriteSize / 2;
    const glow = spriteContext.createRadialGradient(half, half, 0, half, half, half);
    glow.addColorStop(0, withAlpha(color, 1));
    glow.addColorStop(0.14, withAlpha(color, 0.85));
    glow.addColorStop(0.4, withAlpha(color, 0.2));
    glow.addColorStop(1, withAlpha(color, 0));
    spriteContext.fillStyle = glow;
    spriteContext.fillRect(0, 0, spriteSize, spriteSize);

    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);
      // Two flies passing should add their light, not clip each other.
      context.globalCompositeOperation = "lighter";
    };
    resize();

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const spawn = (): Fly => ({
      x: random(0, width),
      y: random(0, height),
      heading: random(0, Math.PI * 2),
      speed: config.drift * random(0.6, 1.4),
      period: config.period * random(0.75, 1.3),
      offset: random(0, config.period * 1.3),
      scale: random(0.8, 1.25),
    });

    let flies = Array.from({ length: count }, spawn);

    /** Fast rise, long decay, then dark. The shape is the whole effect. */
    const brightnessAt = (fly: Fly, time: number) => {
      const t = (time + fly.offset) % fly.period;
      if (t < RISE) return t / RISE;
      if (t < RISE + DECAY) return Math.pow(1 - (t - RISE) / DECAY, 1.9);
      return 0;
    };

    const draw = (fly: Fly, brightness: number) => {
      if (brightness <= 0.01) return;
      const radius = config.glow * fly.scale * (0.55 + brightness * 0.6);
      context.globalAlpha = brightness;
      context.drawImage(sprite, fly.x - radius, fly.y - radius, radius * 2, radius * 2);
    };

    // Reduced motion: the flies held at points spread across the flash
    // envelope, so some are bright, some are fading and some are dark.
    // The uneven brightness is the rhythm, stated in one frame.
    if (reduced) {
      const still = () => {
        context.clearRect(0, 0, width, height);
        flies.forEach((fly, index) => {
          const spread = index / Math.max(1, flies.length - 1);
          draw(fly, spread < 0.55 ? Math.pow(1 - spread / 0.55, 1.4) : 0);
        });
        context.globalAlpha = 1;
      };
      still();
      const onResizeStill = () => {
        resize();
        flies = Array.from({ length: count }, spawn);
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let elapsed = 0;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;
      context.clearRect(0, 0, width, height);

      for (const fly of flies) {
        // The random walk goes on the heading. On the position it would
        // be noise; here it is a long, aimless curve.
        fly.heading += (Math.random() - 0.5) * 1.8 * delta;
        fly.x += Math.cos(fly.heading) * fly.speed * delta;
        fly.y += Math.sin(fly.heading) * fly.speed * delta;

        const margin = config.glow * 2;
        if (fly.x < -margin) fly.x = width + margin;
        if (fly.x > width + margin) fly.x = -margin;
        if (fly.y < -margin) fly.y = height + margin;
        if (fly.y > height + margin) fly.y = -margin;

        draw(fly, brightnessAt(fly, elapsed));
      }

      context.globalAlpha = 1;
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      for (const fly of flies) {
        fly.x = Math.min(fly.x, width);
        fly.y = Math.min(fly.y, height);
      }
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, color]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

Dusk for a screen that should feel restful — a sleep mode, a wind-down state, a dark empty view. The flash envelope is asymmetric: up in about a tenth of a second, down over most of a second, then a long dark gap. A sine wave on opacity gives you dots that pulse, while the fast rise and slow decay says a light was switched on and is dying away, and the seconds of nothing in between are what make a flash an event rather than a rhythm. An unlit fly is not drawn at all. The drift is a random walk on each fly's heading rather than on its position — jittering a position reads as noise, while a wandering heading produces the long aimless curves that read as flight.

Sleep modeWind-down screenDark empty stateEvening theme background

Related effects