← All particles

Bokeh Float

Soft out-of-focus circles rising slowly, blurred and dimmed by depth.

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

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

/**
 * Vibary · Bokeh Float
 *
 * Soft out-of-focus circles rising through the frame. One number does
 * all the work: each circle's depth sets its size, the softness of its
 * rim, its brightness and its speed at once. Near circles are large,
 * very soft, dim and quick; far ones are small, crisper, brighter and
 * slow. Choose those four independently and you get randomly sized
 * dots; tie them to a single depth and the frame acquires distance.
 *
 * The discs themselves are drawn once into cached sprites and blitted,
 * because a real bokeh circle is a gradient with a bright rim and
 * rebuilding one per circle per frame is the expensive way to get it.
 *
 * 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 BokehFloatProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Circles in the frame at once. Overrides the variant's density. */
  count?: number;
  /** Circle tints, sampled per circle. */
  colors?: string[];
  /** Overall strength, 0–1. Bokeh should sit under content, not over it. */
  opacity?: number;
};

type VariantConfig = {
  /** Circles in the frame at this setting. */
  count: number;
  /** Rise speed in px per second for the farthest circles. */
  slowRise: number;
  /** Rise speed in px per second for the nearest circles. */
  fastRise: number;
  /** Radius in px at the far plane and the near plane. */
  smallest: number;
  largest: number;
  /** Sideways sway amplitude in px per second. */
  sway: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely moving lights, mostly far away.
  subtle: { count: 18, slowRise: 5, fastRise: 12, smallest: 5, largest: 24, sway: 2 },
  // Reads as a defocused background. All-purpose.
  default: { count: 26, slowRise: 7, fastRise: 18, smallest: 6, largest: 32, sway: 4 },
  // Closer to the lens: bigger, softer, moving up faster.
  playful: { count: 34, slowRise: 11, fastRise: 27, smallest: 7, largest: 42, sway: 7 },
};

type Circle = {
  x: number;
  y: number;
  /** 0 = far plane, 1 = near plane. Everything else derives from this. */
  depth: number;
  radius: number;
  rise: number;
  alpha: number;
  sprite: number;
  phase: number;
};

// Pastels with enough chroma to survive on a pale surface — a white
// bokeh circle at 50% opacity disappears on a light page.
const DEFAULT_COLORS = ["#F5C877", "#8FB6F0", "#F2A8C6"];

// Typed as `number`, not inferred as the literal 5: the divide-by-zero
// guard below is dead code against a literal type, and TypeScript is
// right to say so — but the guard has to survive someone tuning this.
const DEPTH_STEPS: number = 5;

export default function BokehFloat({
  variant = "default",
  count,
  colors = DEFAULT_COLORS,
  opacity = 0.5,
}: BokehFloatProps) {
  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 total = count ?? config.count;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;

    const mix = (from: number, to: number, t: number) => from + (to - from) * t;

    /**
     * One sprite per (depth step × color). The alpha profile is painted
     * first in an arbitrary color, then flooded with the real tint
     * through `source-in` — which is how a gradient can carry any CSS
     * color the caller passes without parsing it into rgba stops.
     */
    let sprites: HTMLCanvasElement[] = [];

    const buildSprites = () => {
      sprites = [];
      for (let step = 0; step < DEPTH_STEPS; step++) {
        const depth = DEPTH_STEPS === 1 ? 0.5 : step / (DEPTH_STEPS - 1);
        const radius = mix(config.smallest, config.largest, depth);
        // Near circles are further out of focus: their rim smears out.
        const rim = mix(0.9, 0.55, depth);
        for (const tint of colors) {
          const sprite = document.createElement("canvas");
          const box = Math.ceil(radius * 2 * ratio);
          sprite.width = box;
          sprite.height = box;
          const paint = sprite.getContext("2d");
          if (!paint) continue;
          paint.setTransform(ratio, 0, 0, ratio, 0, 0);
          const gradient = paint.createRadialGradient(
            radius,
            radius,
            radius * 0.05,
            radius,
            radius,
            radius
          );
          gradient.addColorStop(0, "rgba(0, 0, 0, 0.34)");
          gradient.addColorStop(rim * 0.85, "rgba(0, 0, 0, 0.42)");
          // The bright rim: an out-of-focus highlight is a ring, not a blob.
          gradient.addColorStop(rim, "rgba(0, 0, 0, 0.85)");
          gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
          paint.fillStyle = gradient;
          paint.fillRect(0, 0, radius * 2, radius * 2);
          paint.globalCompositeOperation = "source-in";
          paint.fillStyle = tint;
          paint.fillRect(0, 0, radius * 2, radius * 2);
          sprites.push(sprite);
        }
      }
    };

    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);
      buildSprites();
    };
    resize();

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

    const spawn = (initial: boolean): Circle => {
      const step = Math.floor(Math.random() * DEPTH_STEPS);
      const depth = DEPTH_STEPS === 1 ? 0.5 : step / (DEPTH_STEPS - 1);
      const radius = mix(config.smallest, config.largest, depth);
      return {
        x: random(-radius, width + radius),
        // On the first fill, scatter through the height so the frame
        // starts populated instead of rising in from the bottom edge.
        y: initial ? random(-radius, height + radius) : height + radius,
        depth,
        radius,
        rise: mix(config.slowRise, config.fastRise, depth) * random(0.8, 1.2),
        // Nearer means more defocused, which means dimmer per pixel.
        alpha: mix(0.95, 0.42, depth) * random(0.75, 1),
        sprite: step * colors.length + Math.floor(Math.random() * colors.length),
        phase: random(0, Math.PI * 2),
      };
    };

    let circles = Array.from({ length: total }, () => spawn(true));
    // Far circles first, so the near soft ones sit over them.
    const byDepth = () => circles.sort((a, b) => a.depth - b.depth);
    byDepth();

    const render = () => {
      context.clearRect(0, 0, width, height);
      for (const circle of circles) {
        const sprite = sprites[circle.sprite];
        if (!sprite) continue;
        context.globalAlpha = circle.alpha * opacity;
        context.drawImage(
          sprite,
          circle.x - circle.radius,
          circle.y - circle.radius,
          circle.radius * 2,
          circle.radius * 2
        );
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: one still frame. Defocused lights at rest are
    // still defocused lights — nothing about the idea needs the drift.
    if (reduced) {
      render();
      const onResizeStill = () => {
        resize();
        circles = Array.from({ length: total }, () => spawn(true));
        byDepth();
        render();
      };
      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;

      for (const circle of circles) {
        circle.y -= circle.rise * delta;
        circle.x += Math.sin(elapsed * 0.3 + circle.phase) * config.sway * delta;
        if (circle.y + circle.radius < 0) {
          const next = spawn(false);
          // Keep the sort order valid: reuse this circle's depth slot.
          Object.assign(circle, next, {
            depth: circle.depth,
            radius: circle.radius,
            sprite: circle.sprite,
            rise: circle.rise,
            y: height + circle.radius,
          });
        }
      }

      render();
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      circles = Array.from({ length: total }, () => spawn(true));
      byDepth();
    };
    window.addEventListener("resize", onResize);

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

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

About this effect

A defocused background for a promo panel, a hero, or an upgrade screen — light that has atmosphere without a photograph. The reason it reads as depth rather than as assorted dots is that one value per circle drives four things at once: size, rim softness, brightness and rise speed. Near circles are large, soft, dim and quick; far ones are small, crisper, brighter and slow. Each disc is a cached sprite with a bright rim, so the frame stays cheap even though every circle is a gradient.

Upgrade panelHero backgroundOnboarding screenMusic player backdrop

Related effects