Bokeh
Dreamy out-of-focus lights: a parallax field of soft circular light discs — each with a brighter aperture rim — drifting slowly through the dark, sized and layered by depth like night lights through a defocused lens, with a few sharp in-focus specks. 2D canvas with additive blending, seeded PRNG for a deterministic scatter, pauses offscreen, honors prefers-reduced-motion with a still scatter.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface BokehProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Light colors, sampled across the depth field. Defaults to a warm-neutral
* palette — champagne, soft amber, warm ivory with one cool accent — so it
* reads like city lights through a defocused lens. Pass any palette; e.g.
* `["#5eead4", "#818cf8", "#f0abfc"]` for a cool jewel look.
* @default ["#ffdca8", "#ffe7cf", "#f3e2c4", "#bcd0ff"]
*/
colors?: string[];
/** Number of light discs in the field. Clamped 8–140. @default 40 */
density?: number;
/** Drift-speed multiplier. 1 = default, 2 = twice as fast. @default 1 */
speed?: number;
/** Overall brightness / bloom, 0–2. @default 1 */
intensity?: number;
/** Deterministic seed — the same seed always renders the same scatter. @default 1 */
seed?: number;
/** Freeze the field at a still, aesthetic scatter. @default false */
paused?: boolean;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
}
const DEFAULT_COLORS = ["#ffdca8", "#ffe7cf", "#f3e2c4", "#bcd0ff"];
// Warm near-black bed the lights glow against — never flat #000.
const BG: [number, number, number] = [9, 8, 10];
/** Deterministic mulberry32 PRNG — same seed always produces the same sequence. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Normalize any CSS color string to [r,g,b] using a throwaway 1×1 fill. */
function colorToRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
}
/**
* Pre-render one soft out-of-focus bokeh disc per palette color: a gently lit
* flat interior with a slightly brighter aperture rim near the edge, fading to
* transparent — the signature look of a light thrown out of focus by a fast
* lens. Drawn additively so overlapping discs bloom.
*/
function makeDisc(rgb: [number, number, number], D: number): HTMLCanvasElement {
const c = document.createElement("canvas");
c.width = D;
c.height = D;
const g = c.getContext("2d")!;
const cx = D / 2;
const [r, gg, b] = rgb;
// Rim pushed a little toward white — the brighter aperture edge.
const rr = Math.round(r * 0.55 + 255 * 0.45);
const rg = Math.round(gg * 0.55 + 255 * 0.45);
const rb = Math.round(b * 0.55 + 255 * 0.45);
const grad = g.createRadialGradient(cx, cx, 0, cx, cx, cx);
grad.addColorStop(0, `rgba(${r},${gg},${b},0.26)`);
grad.addColorStop(0.5, `rgba(${r},${gg},${b},0.3)`);
grad.addColorStop(0.78, `rgba(${rr},${rg},${rb},0.46)`);
grad.addColorStop(0.9, `rgba(${r},${gg},${b},0.2)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, D, D);
return c;
}
/**
* Pre-render one sharp in-focus speck per palette color: a hot white core in a
* tight colored glow. A few of these punctuate the soft field.
*/
function makeSpeck(rgb: [number, number, number], D: number): HTMLCanvasElement {
const c = document.createElement("canvas");
c.width = D;
c.height = D;
const g = c.getContext("2d")!;
const cx = D / 2;
const [r, gg, b] = rgb;
const grad = g.createRadialGradient(cx, cx, 0, cx, cx, cx);
grad.addColorStop(0, `rgba(255,255,255,1)`);
grad.addColorStop(0.18, `rgba(${Math.round(r * 0.4 + 153)},${Math.round(gg * 0.4 + 153)},${Math.round(b * 0.4 + 153)},0.9)`);
grad.addColorStop(0.5, `rgba(${r},${gg},${b},0.35)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, D, D);
return c;
}
interface Disc {
x: number; // device px
y: number; // device px
z: number; // depth 0 (far/small) → 1 (near/large & softer)
size: number; // draw diameter, device px
colorIndex: number;
focus: boolean; // sharp in-focus speck?
bright: number; // base brightness
vx: number; // drift, device px/s (before speed mult)
vy: number;
swayAmp: number; // gentle orbital sway amplitude, device px
swayFreq: number;
swayPhase: number;
twinkleAmp: number;
twinkleFreq: number;
twinklePhase: number;
}
function makeDisc_(rand: () => number, w: number, h: number, scale: number, paletteLen: number): Disc {
// Bias depth toward "far" so many small soft lights sit behind a few big ones.
const z = rand() * rand();
// ~14% are sharp in-focus specks — the bright punctuation.
const focus = rand() < 0.14;
const size = focus
? (5 + rand() * 9) * scale
: (34 + z * 150) * scale; // out-of-focus discs are large and grow with depth
// Parallax: nearer (bigger) lights drift faster across the frame.
const drift = (6 + z * 26) * scale;
const ang = rand() * Math.PI * 2;
return {
x: rand() * w,
y: rand() * h,
z,
size,
colorIndex: Math.floor(rand() * paletteLen),
focus,
bright: focus ? 0.7 + rand() * 0.3 : 0.28 + z * 0.45,
vx: Math.cos(ang) * drift,
vy: Math.sin(ang) * drift * 0.7,
swayAmp: (4 + z * 16) * scale,
swayFreq: 0.05 + rand() * 0.14,
swayPhase: rand() * Math.PI * 2,
twinkleAmp: focus ? 0.28 : 0.12,
twinkleFreq: focus ? 0.6 + rand() * 1.4 : 0.15 + rand() * 0.4,
twinklePhase: rand() * Math.PI * 2,
};
}
function buildField(density: number, seed: number, w: number, h: number, scale: number, paletteLen: number): Disc[] {
const count = Math.min(140, Math.max(8, Math.round(density)));
const rand = mulberry32(seed);
const out: Disc[] = [];
for (let i = 0; i < count; i++) out.push(makeDisc_(rand, w, h, scale, paletteLen));
// Sort far → near so big near discs layer over small far ones.
out.sort((a, b) => a.z - b.z);
return out;
}
interface Internals {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
scale: number;
discs: HTMLCanvasElement[];
specks: HTMLCanvasElement[];
field: Disc[];
intensity: number;
last: number;
}
function paintBackground(it: Internals) {
const { ctx } = it;
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
// Very dark warm-neutral base with a faint vertical lift so it never reads dead.
const grad = ctx.createLinearGradient(0, 0, 0, it.h);
grad.addColorStop(0, `rgb(${BG[0] + 3},${BG[1] + 2},${BG[2] + 4})`);
grad.addColorStop(1, `rgb(${BG[0]},${BG[1]},${BG[2]})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, it.w, it.h);
}
function drawDisc(it: Internals, d: Disc, t: number, animate: boolean) {
const twinkle = animate ? 1 + d.twinkleAmp * Math.sin(d.twinklePhase + t * d.twinkleFreq * Math.PI * 2) : 1;
const alpha = Math.max(0, Math.min(1, d.bright * twinkle * it.intensity));
if (alpha <= 0.002) return;
const sprite = d.focus ? it.specks[d.colorIndex] : it.discs[d.colorIndex];
const sway = animate ? d.swayAmp * Math.sin(d.swayPhase + t * d.swayFreq * Math.PI * 2) : 0;
const swayY = animate ? d.swayAmp * 0.6 * Math.cos(d.swayPhase * 1.3 + t * d.swayFreq * Math.PI * 2) : 0;
const { ctx } = it;
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = alpha;
const s = d.size;
ctx.drawImage(sprite, d.x + sway - s / 2, d.y + swayY - s / 2, s, s);
}
/** One animated frame: clear, advance drift with edge-wrap, draw additively. */
function tickFrame(it: Internals, dt: number, t: number, speedFactor: number) {
paintBackground(it);
for (const d of it.field) {
const m = d.size; // wrap margin = draw size so discs never pop
d.x += d.vx * speedFactor * dt;
d.y += d.vy * speedFactor * dt;
if (d.x < -m) d.x = it.w + m;
else if (d.x > it.w + m) d.x = -m;
if (d.y < -m) d.y = it.h + m;
else if (d.y > it.h + m) d.y = -m;
drawDisc(it, d, t, true);
}
}
/** Still, handsome scatter for paused / reduced-motion — lights at rest. */
function drawStatic(it: Internals) {
paintBackground(it);
for (const d of it.field) drawDisc(it, d, 0, false);
}
/**
* Bokeh — dreamy out-of-focus lights. A parallax field of soft circular light
* discs drifting slowly through the dark, each a gently-lit disc with a brighter
* aperture rim, sized and layered by depth so the frame reads volumetric — like
* night lights seen through a defocused fast lens. A handful of sharp in-focus
* specks punctuate the softness. 2D canvas with additive blending; seeded PRNG
* for a deterministic scatter (no hydration mismatch), pauses offscreen and on
* hidden tabs, and honors prefers-reduced-motion with a still scatter of lights.
* Warm-neutral by default; pass `colors` for any palette. Drop it inside any
* `relative` container.
*/
export function Bokeh({
colors = DEFAULT_COLORS,
density = 40,
speed = 1,
intensity = 1,
seed = 1,
paused = false,
dpr = 2,
className,
style,
...props
}: BokehProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const speedFactor = Math.max(speed, 0);
const frozen = paused || reducedMotion;
const colorsKey = colors.join(",");
const tick = React.useCallback(
(elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
let dt = elapsed - it.last;
it.last = elapsed;
if (dt > 0.05) dt = 0.05; // clamp tab-restore jumps
if (dt <= 0) return;
tickFrame(it, dt, elapsed, speedFactor);
},
[speedFactor]
);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });
// Set up canvas, sprites and the field; rebuild on size / palette / seed / density.
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const scale = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const palette = colors.length ? colors : DEFAULT_COLORS;
const rgbs = palette.map((c) => colorToRgb(ctx, c));
// Soft discs are big; specks are tiny. Sprite resolution is generous but fixed.
const discSprites = rgbs.map((c) => makeDisc(c, Math.max(64, Math.round(220 * scale))));
const speckSprites = rgbs.map((c) => makeSpeck(c, Math.max(24, Math.round(48 * scale))));
const build = () => {
const w = Math.max(1, Math.round(container.clientWidth * scale));
const h = Math.max(1, Math.round(container.clientHeight * scale));
canvas.width = w;
canvas.height = h;
const it: Internals = {
ctx,
w,
h,
scale,
discs: discSprites,
specks: speckSprites,
field: buildField(density, seed, w, h, scale, rgbs.length),
intensity: Math.max(intensity, 0),
last: 0,
};
internalsRef.current = it;
// Paint one frame immediately so mount/resize never flashes empty.
if (frozen) drawStatic(it);
else {
paintBackground(it);
for (const d of it.field) drawDisc(it, d, 0, false);
}
};
const ro = new ResizeObserver(build);
ro.observe(container);
build();
return () => {
ro.disconnect();
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, seed, colorsKey, dpr, intensity]);
// Re-render the still frame in place when frozen toggles / intensity tweaks.
React.useEffect(() => {
const it = internalsRef.current;
if (!it) return;
it.intensity = Math.max(intensity, 0);
if (frozen) drawStatic(it);
}, [frozen, intensity]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="bokeh"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={style}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/bokehProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | string[] | ["#ffdca8", "#ffe7cf", "#f3e2c4", "#bcd0ff"] | Light colors, sampled across the depth field. Defaults to a warm-neutral palette — champagne, soft amber, warm ivory with one cool accent — so it reads like city lights through a defocused lens. Pass any palette; e.g. ["#5eead4", "#818cf8", "#f0abfc"] for a cool jewel look. |
| density | number | 40 | Number of light discs in the field. Clamped 8–140. |
| speed | number | 1 | Drift-speed multiplier. 1 = default, 2 = twice as fast. |
| intensity | number | 1 | Overall brightness / bloom, 0–2. |
| seed | number | 1 | Deterministic seed — the same seed always renders the same scatter. |
| paused | boolean | false | Freeze the field at a still, aesthetic scatter. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.