"use client";
import * as React from "react";
export type BokehPalette = "ember" | "mint" | "dusk" | "mono";
export type BokehAperture = "circle" | "hexagon";
export interface BokehFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of light circles. */
count?: number;
/** Named colour palette. Ignored when `colors` is given. "mono" follows the text colour. */
palette?: BokehPalette;
/** Custom colours (any CSS colours); overrides `palette`. */
colors?: string[];
/** Drift speed multiplier. 0 holds the lights still. */
speed?: number;
/** Diameter of the nearest lights, in px. Far lights are about a third of this. */
size?: number;
/** How out of focus the near lights are, 0–1. */
blur?: number;
/** Lens aperture shape. */
aperture?: BokehAperture;
/** Shift the layers by depth as a mouse pointer moves. */
parallax?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Sprites ---------------------------------- */
const PALETTES: Record<Exclude<BokehPalette, "mono">, string[]> = {
ember: ["#FF7448", "#FFB38A", "#F2D17B"],
mint: ["#B6F3D1", "#7FD8BE", "#E6FFF4"],
dusk: ["#FF7448", "#9DB4FF", "#E7A3C8"],
};
const SPRITE = 160; // sprite edge in px; lights are drawn scaled from these
const BLUR_LEVELS = 4;
const OFFSET = 4000; // shadow trick: the shape is drawn off-canvas, only its blurred shadow lands
function shapePath(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, aperture: BokehAperture) {
ctx.beginPath();
if (aperture === "hexagon") {
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i + Math.PI / 12; // slightly rotated blades read more like a lens
if (i === 0) ctx.moveTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
else ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
}
ctx.closePath();
} else {
ctx.arc(cx, cy, r, 0, Math.PI * 2);
}
}
/**
* Pre-renders one blurred light per colour and blur level. The blur comes from `shadowBlur`
* (supported everywhere, unlike `ctx.filter`) and is paid once here, never per frame.
*/
function renderSprite(color: RGB, blurPx: number, aperture: BokehAperture) {
const canvas = document.createElement("canvas");
canvas.width = SPRITE;
canvas.height = SPRITE;
const ctx = canvas.getContext("2d");
if (!ctx) return canvas;
const r = SPRITE / 2 - blurPx * 1.5 - 2;
ctx.shadowColor = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
ctx.shadowBlur = blurPx;
ctx.shadowOffsetX = OFFSET;
shapePath(ctx, SPRITE / 2 - OFFSET, SPRITE / 2, r, aperture);
ctx.fillStyle = "rgba(0, 0, 0, 0.42)";
ctx.fill();
// A brighter rim, like real out-of-focus highlights.
ctx.lineWidth = Math.max(1.5, r * 0.07);
ctx.strokeStyle = "rgba(0, 0, 0, 0.75)";
ctx.stroke();
return canvas;
}
/* -------------------------------- Component -------------------------------- */
const PARALLAX_PX = 36;
/**
* Out-of-focus light circles drifting at different depths. Depth sets size, blur and opacity
* (near lights are big, soft and faint; far lights small and crisper) and how far each light
* moves with the pointer parallax. All blur is baked into sprites up front.
*/
export function BokehField({
count = 40,
palette = "ember",
colors,
speed = 1,
size = 120,
blur = 0.6,
aperture = "circle",
parallax = true,
className = "",
children,
...props
}: BokehFieldProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const colorKey = colors?.join("|") ?? "";
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const total = Math.max(1, Math.round(count));
const maxR = Math.max(8, size / 2);
const softness = Math.min(1, Math.max(0, blur));
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let time = 0;
let inView = false;
let dark = true;
let sprites: HTMLCanvasElement[][] = []; // [colour][blur level]
// Lights sorted far → near so nearer ones draw on top.
const lights = Array.from({ length: total }, () => ({
x: 0,
y: 0,
depth: Math.random(),
tone: 0,
phase: Math.random() * Math.PI * 2,
wobble: 0.4 + Math.random() * 0.8,
})).sort((a, b) => a.depth - b.depth);
const ptr = { tx: 0, ty: 0, x: 0, y: 0 };
const buildSprites = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
dark = luminance(ink) > 0.5;
const list = colorKey ? colorKey.split("|") : palette === "mono" ? [] : PALETTES[palette];
let rgbs = list.map((c) => resolveColor(c, [255, 116, 72]));
if (!rgbs.length) rgbs = [ink];
// On light themes, pale colours are deepened (more saturated, a little darker, same hue)
// so the circles stay visible without turning grey.
if (!dark) {
rgbs = rgbs.map((c) => {
if (luminance(c) <= 0.6) return c;
const mean = (c[0] + c[1] + c[2]) / 3;
return c.map((v) => Math.round(Math.min(255, Math.max(0, mean + (v - mean) * 1.4)) * 0.84)) as RGB;
});
}
sprites = rgbs.map((rgb) =>
Array.from({ length: BLUR_LEVELS }, (_, b) => renderSprite(rgb, 1.5 + (b / (BLUR_LEVELS - 1)) * softness * 18, aperture)),
);
lights.forEach((l, i) => (l.tone = i % sprites.length));
};
const radiusOf = (depth: number) => maxR * (0.32 + 0.68 * depth);
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = dark ? "lighter" : "source-over";
for (const l of lights) {
const r = radiusOf(l.depth);
const twinkle = 0.82 + 0.18 * Math.sin(time * 0.8 * l.wobble + l.phase);
const base = dark ? 0.62 - 0.32 * l.depth : 0.55 - 0.28 * l.depth;
const x = l.x + ptr.x * l.depth;
const y = l.y + ptr.y * l.depth;
const level = Math.min(BLUR_LEVELS - 1, Math.round(l.depth * (BLUR_LEVELS - 1)));
ctx.globalAlpha = base * twinkle;
// The sprite's shape is inset from its edge, so scale it so the shape spans 2r.
const scale = (2 * r) / SPRITE / 0.82;
const s = SPRITE * scale;
ctx.drawImage(sprites[l.tone][level], x - s / 2, y - s / 2, s, s);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
};
const advance = (f: number) => {
for (const l of lights) {
const r = radiusOf(l.depth);
// Near lights drift faster: depth reads as motion parallax even without a pointer.
l.y -= (0.08 + 0.32 * l.depth) * speed * f;
l.x += Math.sin(time * 0.3 * l.wobble + l.phase) * 0.12 * speed * f;
if (l.y < -r * 1.4 - PARALLAX_PX) {
l.y = height + r * 1.4 + PARALLAX_PX;
l.x = Math.random() * width;
}
if (l.x < -r * 1.4 - PARALLAX_PX) l.x = width + r * 1.4;
else if (l.x > width + r * 1.4 + PARALLAX_PX) l.x = -r * 1.4;
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
const f = dt / 16.7;
time += (dt / 1000) * speed;
advance(f);
const ease = 1 - Math.pow(0.94, f);
ptr.x += (ptr.tx - ptr.x) * ease;
ptr.y += (ptr.ty - ptr.y) * ease;
draw();
const settled = Math.abs(ptr.tx - ptr.x) < 0.05 && Math.abs(ptr.ty - ptr.y) < 0.05;
if (speed > 0 || !settled) schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const scatter = () => {
for (const l of lights) {
l.x = Math.random() * width;
l.y = Math.random() * height;
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
const first = width === 0;
const sx = width ? rect.width / width : 1;
const sy = height ? rect.height / height : 1;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (first) scatter();
else
for (const l of lights) {
l.x *= sx;
l.y *= sy;
}
draw();
schedule();
};
const onThemeChange = () => {
buildSprites();
if (!raf) draw();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const rect = canvas.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width - 0.5;
const ny = (e.clientY - rect.top) / rect.height - 0.5;
const inside = Math.abs(nx) <= 0.5 && Math.abs(ny) <= 0.5;
// Lights move against the pointer, as if the camera pans toward it.
ptr.tx = inside ? -nx * 2 * PARALLAX_PX : 0;
ptr.ty = inside ? -ny * 2 * PARALLAX_PX : 0;
schedule();
};
const onPointerLeave = () => {
ptr.tx = 0;
ptr.ty = 0;
schedule();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listenPointer = parallax && !reduceMotion;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (listenPointer) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
}
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
stop();
}
});
buildSprites();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [count, palette, colorKey, speed, size, blur, aperture, parallax, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}