# Bokeh — Animated Background for React

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

- Page: https://crucibleui.com/components/backgrounds/bokeh
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/bokeh
```

Registry JSON: https://crucibleui.com/r/bokeh.json

Import from `@/components/crucible/bokeh`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Bokeh } from "@/components/crucible/bokeh";
import { DemoScene } from "@/components/registry/demo-scene";

export default function BokehDemo() {
  return (
    <DemoScene
      background={<Bokeh />}
      headline="Softer than you remember"
      subhead="Out-of-focus lights drifting slowly through the dark."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`bokeh.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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: internalsRef.current?.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]);

  // 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>
  );
}
```
