← All particles

Plankton Drift

Faint motes in deep water that brighten as the current takes them.

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

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

/**
 * Vibary · Plankton Drift
 *
 * Faint motes suspended in deep water, brightening as the current takes
 * them and dimming as it lets go.
 *
 * The technique: brightness is coupled to speed, and speed comes from a
 * flow field shared by every mote. Bioluminescence responds to movement
 * through water, so a mote lights up because the current found it — and
 * because the current is one continuous field, a whole region brightens
 * together and the glow travels across the frame as a slow wave.
 * Per-particle random blinking gives you the same average brightness and
 * none of that: it reads as static, because nothing is agreeing with
 * anything.
 *
 * 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 PlanktonDriftProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Motes in the water. */
  count?: number;
  /** Mote color — this one wants a dark surface behind it. */
  color?: string;
};

type VariantConfig = {
  /** Peak current speed in px per second. */
  flow: number;
  /** How much of the brightness comes from speed rather than baseline. */
  gain: number;
  /** Glow diameter in px at scale 1. */
  size: number;
  /** Brightness with no current at all. */
  base: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly still water. The motes barely move and barely light.
  subtle: { flow: 9, gain: 0.5, size: 5, base: 0.08 },
  // A slow current crossing the frame. All-purpose.
  default: { flow: 16, gain: 0.72, size: 6.5, base: 0.1 },
  // A livelier drift, with the bright wave clearly readable.
  playful: { flow: 27, gain: 0.9, size: 8.5, base: 0.12 },
};

type Mote = {
  x: number;
  y: number;
  scale: number;
  /** How strongly this one answers the current. */
  response: number;
  /** A little private motion, so the field isn't a rigid body. */
  wanderPhase: 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 PlanktonDrift({
  variant = "default",
  count = 90,
  color = "#8FD8E8",
}: PlanktonDriftProps) {
  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 into an offscreen canvas and blitted per
    // mote. Building a radial gradient per mote per frame is the usual
    // way this ends up costing more than it should.
    const sprite = document.createElement("canvas");
    const spriteSize = 32;
    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.25, withAlpha(color, 0.62));
    glow.addColorStop(0.6, withAlpha(color, 0.16));
    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);
      // Overlapping motes should add up rather than hide each other.
      // This only affects blending inside the canvas; the canvas itself
      // still composites normally over whatever is behind it.
      context.globalCompositeOperation = "lighter";
    };
    resize();

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

    const spawn = (): Mote => ({
      x: random(0, width),
      y: random(0, height),
      scale: random(0.55, 1.4),
      response: random(0.7, 1.3),
      wanderPhase: random(0, Math.PI * 2),
    });

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

    /**
     * The shared current. Two low-frequency waves per axis, out of step
     * with each other, which is enough to look like water and cheap
     * enough to sample twice per mote per frame.
     */
    const flowAt = (x: number, y: number, time: number) => {
      const u = x * 0.009;
      const v = y * 0.012;
      return {
        x: (Math.sin(v * 1.3 + time * 0.31) + Math.sin(v * 0.47 - time * 0.19) * 0.7) * config.flow,
        y: (Math.cos(u * 1.1 - time * 0.26) * 0.8 + Math.sin(u * 0.4 + time * 0.14) * 0.5) * config.flow,
      };
    };

    const draw = (mote: Mote, speed: number) => {
      // Brightness from speed. The clamp matters: without it the fastest
      // motes blow out and the field starts to sparkle.
      const lit = Math.min(1, config.base + (speed / config.flow) * config.gain * mote.response);
      const size = config.size * mote.scale * (0.8 + lit * 0.5);
      context.globalAlpha = lit;
      context.drawImage(sprite, mote.x - size, mote.y - size, size * 2, size * 2);
    };

    // Reduced motion: the field held still, each mote lit by the current
    // it happens to be sitting in. The uneven brightness is the subject,
    // and it survives without any movement at all.
    if (reduced) {
      const still = () => {
        context.clearRect(0, 0, width, height);
        for (const mote of motes) {
          const flow = flowAt(mote.x, mote.y, 0);
          draw(mote, Math.hypot(flow.x, flow.y));
        }
        context.globalAlpha = 1;
      };
      still();
      const onResizeStill = () => {
        resize();
        motes = 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 mote of motes) {
        const flow = flowAt(mote.x, mote.y, elapsed);
        // A trace of private drift so the field isn't perfectly rigid.
        const wanderX = Math.sin(elapsed * 0.6 + mote.wanderPhase) * config.flow * 0.12;
        const wanderY = Math.cos(elapsed * 0.5 + mote.wanderPhase * 1.7) * config.flow * 0.12;
        const vx = flow.x * mote.response + wanderX;
        const vy = flow.y * mote.response + wanderY;

        mote.x += vx * delta;
        mote.y += vy * delta;

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

        draw(mote, Math.hypot(vx, vy));
      }

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

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      motes = Array.from({ length: count }, spawn);
    };
    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

Ambient depth for a dark surface — a focus mode, a night theme, the background of a player or a reading view. Brightness is coupled to speed, and speed comes from one flow field shared by every mote: a mote lights because the current found it, and since the current is continuous a whole region brightens together, so the glow crosses the frame as a slow wave. Random per-particle blinking produces the same average brightness and none of that quality — it reads as static, because nothing is agreeing with anything. The glow is drawn once into an offscreen sprite and blitted, so ninety motes cost ninety image draws rather than ninety gradients.

Focus mode backgroundDark theme ambienceMedia player backdropNight reading view

Related effects