← 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