freeNew214 lines · framer-motion
Bokeh
Light, caught just out of focus. A full-width section backed by soft, heavily blurred bokeh circles drifting on slow independent lissajous paths at different sizes and depths, under a short glass-card headline.
- Use it for
- A warm, calm section break for a brand, lifestyle or creative-tool page that wants depth and light instead of sharp geometry.
- Themes
- Light + dark
- License
- Commercial use
Live
Soft focus
Light, caught just out of focus
Slow drifting bokeh circles at different depths.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width section backed by soft, out-of-focus bokeh circles drifting on slow
* lissajous paths at different depths (size, blur and opacity), like light caught
* behind camera glass. A glass card holds a short headline on top.
*/
export interface BgBokehProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
/** Drift speed multiplier. */
speed?: number;
/** Number of bokeh circles. */
count?: number;
/** Token used for the circle colour. */
accent?: "signal" | "info" | "lime" | "ok";
className?: string;
}
type RGB = [number, number, number];
function hexToRgb(hex: string, fallback: RGB): RGB {
const h = hex.replace("#", "").trim();
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(v, 16);
if (Number.isNaN(n) || v.length < 6) return fallback;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function readVar(name: string, fallback: RGB): RGB {
if (typeof window === "undefined") return fallback;
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return raw ? hexToRgb(raw, fallback) : fallback;
}
const ACCENT_FALLBACK: Record<string, RGB> = {
signal: [255, 90, 31],
info: [41, 82, 255],
lime: [200, 240, 60],
ok: [19, 138, 75],
};
interface Dot {
cx: number;
cy: number;
rx: number;
ry: number;
fx: number;
fy: number;
phase: number;
r: number;
blur: number;
alpha: number;
warm: boolean;
}
export function BgBokeh({
eyebrow = "Soft focus",
headline = "Light, caught just out of focus",
subcopy = "Slow drifting bokeh circles at different depths.",
speed = 1,
count = 16,
accent = "signal",
className,
}: BgBokehProps) {
const reduce = useReducedMotionSafe();
const hostRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf = 0;
let visible = true;
let w = 0;
let h = 0;
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let warmColor: RGB = ACCENT_FALLBACK[accent];
let coolColor: RGB = [160, 160, 150];
const applyTheme = () => {
warmColor = readVar(`--${accent}`, ACCENT_FALLBACK[accent]);
coolColor = readVar("--muted-foreground", [160, 160, 150]);
};
applyTheme();
const mo = new MutationObserver(applyTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const total = Math.max(4, Math.min(28, count));
const dots: Dot[] = Array.from({ length: total }, (_, i) => {
const seed = i / total;
return {
cx: 0.08 + ((i * 0.618) % 1) * 0.84,
cy: 0.1 + ((i * 0.382 + seed) % 1) * 0.8,
rx: 0.1 + (i % 5) * 0.03,
ry: 0.08 + ((i + 2) % 4) * 0.03,
fx: 0.04 + (i % 3) * 0.02,
fy: 0.03 + ((i + 1) % 3) * 0.015,
phase: i * 0.9,
r: 14 + (i % 6) * 10,
blur: 6 + (i % 4) * 5,
alpha: 0.12 + (i % 5) * 0.03,
warm: i % 3 !== 0,
};
});
const resize = () => {
w = host.clientWidth;
h = host.clientHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting));
io.observe(host);
const draw = (t: number) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
for (const d of dots) {
const x = (d.cx + Math.sin(t * d.fx + d.phase) * d.rx) * w;
const y = (d.cy + Math.cos(t * d.fy + d.phase) * d.ry) * h;
const [cr, cg, cb] = d.warm ? warmColor : coolColor;
ctx.filter = `blur(${d.blur}px)`;
const g = ctx.createRadialGradient(x, y, 0, x, y, d.r);
g.addColorStop(0, `rgba(${cr},${cg},${cb},${d.alpha})`);
g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, d.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.filter = "none";
};
let elapsed = 0;
let last = performance.now();
const loop = () => {
raf = requestAnimationFrame(loop);
if (!visible || document.hidden) return;
const now = performance.now();
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
elapsed += dt * speed;
draw(elapsed);
};
if (reduce) {
draw(1.2);
} else {
loop();
}
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
mo.disconnect();
};
}, [speed, count, accent, reduce]);
return (
<section className={cn("relative isolate overflow-hidden bg-background", className)}>
<div ref={hostRef} className="absolute inset-0">
<canvas ref={canvasRef} className="absolute inset-0" aria-hidden />
</div>
<div className="absolute inset-0 bg-gradient-to-b from-background/0 via-background/0 to-background/55" aria-hidden />
<div className="relative mx-auto flex min-h-[22rem] max-w-3xl flex-col items-center justify-center px-4 py-20 text-center sm:min-h-[26rem] sm:px-6">
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.5 }}
className="rounded-[2rem] border border-border/60 bg-background/55 px-8 py-10 backdrop-blur-sm sm:px-14 sm:py-12"
>
{eyebrow && (
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</span>
)}
<h2 className="mt-4 font-display text-[clamp(2rem,5vw,3.4rem)] font-extrabold leading-[1.03] tracking-[-0.03em]">{headline}</h2>
{subcopy && <p className="mt-4 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>}
</motion.div>
</div>
</section>
);
}
export default BgBokeh;
How it behaves
- →Each circle is a blurred radial gradient drifting on its own lissajous orbit, with its own size, blur radius and opacity fixed at mount for a believable depth-of-field spread.
- →Roughly two thirds of the circles use the chosen accent token and the rest use the muted-foreground token, so the field reads as warm highlights among neutral ones; colours re-read from CSS variables on theme change.
- →count sets how many circles drift; speed scales orbit speed.
- →Pauses when the section scrolls offscreen or the tab is hidden; prefers-reduced-motion renders one settled frame instead of looping.
Accessibility
- ✓Canvas is aria-hidden; only the heading and copy are in the accessibility tree.
- ✓Headline sits on a translucent blurred card for contrast against the bokeh in both themes.
- ✓Honours prefers-reduced-motion.