Backgrounds

Bokeh Lights

Slowly floating out-of-focus light discs on one canvas — seeded for a reproducible picture, blitted from a pre-rendered sprite instead of a per-frame blur.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const TAU = Math.PI * 2
/** A backgrounded tab resumes with a huge gap — clamp so nothing teleports. */
const MAX_DT = 1 / 30
/** 3x backing stores cost 2.25x the fill for no visible gain on an out-of-focus disc. */
const MAX_DPR = 2
/** The only thing between a typo'd `count` and a frozen tab. */
const MAX_LIGHTS = 160
/** A light needs this much canvas to itself; small cards get proportionally fewer. */
const DEFAULT_DENSITY = 14000

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/bokeh-lights.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BokehLights" component — slowly floating
out-of-focus light discs painted on one canvas, used as a hero / card backdrop.
Its only dependency is a cn() class merger (clsx + tailwind-merge).

Contract
- export function BokehLights(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - count?: number (default 22) — requested discs, clamped to 0..160 and also
    capped by area (see density).
  - size?: [number, number] (default [30, 110]) — disc radius range in CSS px.
  - speed?: number (default 1) — multiplier on drift, sway and breathing. 0
    freezes the field AND the rAF loop never starts at all.
  - seed?: number (default 5) — integer seed. Same seed, same raster, byte for
    byte.
  - intensity?: number (default 0.28) — peak opacity of a disc, 0..1. This is
    the legibility knob, see below.
  - edge?: number (default 0.6) — 0 is a plain gaussian-ish blob, 1 is the
    bright rim a real lens leaves around an out-of-focus highlight.
  - colors?: string[] (default ["chart-1","chart-3","chart-5"]) — theme token
    names WITHOUT the leading "--", cycled across the discs by hash.
  - density?: number (default 14000) — CSS px squared of canvas required per
    disc; the area cap on count, so a small card is not carpeted.
  - children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat non-finite values as the
  default. A NaN count empties the field, density 0 asks for an infinite one, a
  negative speed runs the drift backwards, a reversed size tuple hands Math a
  negative range.

Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
  aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
  size-full matters, an absolutely positioned replaced element with inset-0
  alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
  children. The canvas has no tabindex, so the decoration adds no Tab stop and
  can never intercept a click. The component paints NO background of its own:
  the surface belongs to the consumer.
- SOFT FOCUS IS A SPRITE, NOT A FILTER. This is the one decision that defines
  the component. Rasterise ONE sprite per palette colour, once per
  (colour, max radius, dpr), and draw every disc as a scaled drawImage of it.
  Do NOT set ctx.filter = "blur(Npx)" per disc per frame. Measured in Edge on
  the same field (1232x400 canvas, discs of radius 30..110), two runs, frame time
  forced to include raster by reading one pixel back each frame:
      discs   per-frame blur     per-frame radial gradient   sprite blit
        20     80.8 / 89.4 ms          3.1 / 2.9 ms          6.5 / 6.0 ms
        50    218.2 / 154.6 ms        10.3 / 6.7 ms         15.0 / 13.2 ms
       100    405.2 / 350.9 ms        13.0 / 14.5 ms         3.1 / 2.7 ms
       160    761.8 / 474.6 ms        19.6 / 21.9 ms         3.0 / 2.3 ms
  Achieved frame rate at 100 discs: 47-49 fps for the blur, a vsync-capped 120
  for both of the others. Watch the trap: measuring only the rAF callback shows
  the blur costing 0.2-0.4 ms, because the JS call just enqueues work — the cost
  is real and it lands in raster.
  Sprite over per-frame gradient: their raster cost is comparable (and the
  readback needed to force raster distorts them in opposite directions), but the
  sprite's MAIN-THREAD time is flat at 0.0-0.2 ms for any count while rebuilding
  160 six-stop gradients per frame costs 0.9-1.6 ms — 8-16x more of the budget
  that competes with React and layout. The sprite also gets the rim profile at
  any radius for free.
- Sprite construction, without hard-coding a colour: fill the sprite square with
  the token colour string read from getComputedStyle(canvas).getPropertyValue
  ("--<token>"), then switch to globalCompositeOperation "destination-in" and
  fill it again with a radial gradient whose stops carry ONLY alpha. The
  operator discards the mask's RGB, so the visible ink is 100% theme token and
  nothing ever parses oklch()/color-mix() by hand. Guard the tint: set
  fillStyle="transparent" first and read fillStyle back — an unparsable string
  leaves it untouched, and without the sentinel you silently ship black discs.
  Fall back to getComputedStyle(canvas).color.
- Alpha profile: sample the radius at [0, .3, .55, .78, .9, 1] and lerp between
  a soft ramp [1, .78, .42, .17, .07, 0] and a rim ramp [.22, .28, .55, 1, .4, 0]
  by `edge`, then NORMALISE so the peak is 1 — otherwise `intensity` would mean
  a different brightness at every `edge`.
- Deterministic field: every disc property is an integer hash of (id, salt),
  where id mixes the seed with the disc's index. Address discs by index instead
  of pulling from a PRNG stream, so growing the field after a resize never
  reshuffles the discs already on screen. Math.random() is never called — not in
  render (purity/SSR) and not in the loop (screenshots must be reproducible).
  Radius is drawn as min + (max-min) * h^2 so small discs outnumber big ones,
  which is what depth of field actually does; a field of uniformly large discs
  reads as soup.
- Motion: each disc drifts along a mostly-upward heading at 3..12 CSS px/s, adds
  a sine sway on x, and breathes in brightness (0.78..1.0) and a little in scale
  (±5%) on its own phase and rate. Wrap on the full disc (margin = radius) so
  nothing ever pops in or out at an edge. dt is clamped to 1/30 s so a
  backgrounded tab cannot teleport the field on resume.
- LEGIBILITY IS PART OF THE CONTRACT. `intensity` is the budget. Measured worst
  pixel behind a text box over a 3s animated window, canvas pixel composited
  over the real surface:
      intensity   text-foreground (light / dark)
        0.14         12.36 / 11.09
        0.20         10.10 /  8.60
        0.28 (dflt)   7.65 /  6.12
        0.36          -    /  4.59
        0.40          5.20 /  4.03  <- dark already below AA
  Dark is the binding theme. Overlaid copy must use text-foreground; do not use
  text-muted-foreground, which starts at 4.95:1 on white and lands at 2.22:1
  over this field — that is a property of the token, not of this component.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
  padding would offset the box); its first callback is the initial sizing. Try
  observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
  browsers that do not know that box throw a WebIDL TypeError from observe()
  rather than ignoring it — and fall back to observe(canvas). Take the MAX of
  window.devicePixelRatio and (deviceBoxWidth / cssWidth), clamped to 1..2:
  neither source is trustworthy alone. Measured here, an emulated 2x surface
  reports the device box in CSS px (1220 for a 1220px box) while devicePixelRatio
  says 2; trusting the box alone ships a 1x canvas at 2x and 3x, i.e. a visibly
  blurry backdrop. Re-apply ctx.setTransform after every resize (writing
  canvas.width resets the context) and derive the scale from the actual backing
  size. Bail out early when the CSS box and the dpr are both unchanged, and
  rescale existing discs in place instead of reseeding them.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
  on screen, document.visibilityState is "visible", motion is allowed, and
  speed > 0.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
  the tokens; rebuild the sprites only when the resolved values actually changed,
  and repaint the still frame when the loop is paused. No settle delay is needed
  — custom properties are not animatable, so unlike a transition-colors
  background they read their final value immediately.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
  snapshot false, so it is hydration-safe) and keep it in the effect deps. Under
  reduce the loop never starts: exactly one static frame is painted, and because
  the breathing phases are already spread apart at t=0 the discs still vary in
  brightness. It is the same raster speed=0 produces.
- Cleanup on unmount: cancelAnimationFrame, both observers, the MutationObserver,
  the visibilitychange listener, and set every sprite canvas to 0x0 — a detached
  canvas keeps its backing store, and at dpr 2 with the default max radius these
  are three 440x440 surfaces. Cancel the loop BEFORE freeing the sprites: a
  leaked loop that reaches a 0x0 sprite throws InvalidStateError from drawImage
  on every frame (verified by patching stop() out of the cleanup).

Rendering & styling
- Semantic tokens only, zero colour literals in the visible ink: the discs are
  var(--chart-1) / var(--chart-3) / var(--chart-5) (or whatever tokens the
  consumer names) resolved through the canvas's own computed style, so light,
  dark and any rebranded palette come for free. The only literal in the file is
  the alpha mask fed to destination-in, whose colour channels are discarded.
- Merge the consumer className via cn() on the root; the canvas keeps its own
  classes.
- Accessibility: the canvas is aria-hidden, pointer-events-none and has no
  tabindex — pure decoration. Children stay fully interactive and above it.

Customization levers
- Legibility vs drama: `intensity` is the one knob, and the table above is its
  budget. Keep it at or below ~0.36 while copy sits on top; go to 0.5+ only for
  a field with nothing over it.
- Disc character: `edge` reshapes the profile (0 = soft blob, 1 = lens ring),
  `size` sets the radius range, and the h^2 in the radius draw is the size
  distribution — swap it for plain h to get evenly mixed sizes, or h^3 for a few
  hero discs among many tiny ones.
- Palette: `colors` takes any token names. One entry gives a single-tint wash;
  var(--primary) for a branded surface, var(--primary-foreground) for an
  inverted panel.
- Cost: one blit per disc per frame, so `count` is linear and `density` is the
  area guard; MAX_LIGHTS (160) is the hard ceiling. MAX_SPRITE_RADIUS (256
  device px) trades sprite memory for sharpness on very large discs — the sprite
  is a smooth gradient, so upscaling it past that is invisible.
- Motion feel: the drift heading (mostly up), the 3..12 px/s speed range, the
  sway amplitude and the breathing rates are four independent dials; zero the
  sway for a straight rise, or point the heading sideways for a lateral drift.
- Surface: the component paints no background. Put it on bg-card, bg-background
  or an inverted bg-primary panel and the discs composite over whatever is there.

Concepts

  • Pre-rendered sprite blit — the soft focus is baked once into a texture per palette colour and every frame is a drawImage. The alternative, ctx.filter = "blur()" around a solid arc, re-runs a gaussian blur per disc per frame: measured 81–89 ms/frame at 20 discs and 351–405 ms at 100, against 6 ms and 3 ms for the sprite, and a frame rate of 47–49 fps against a vsync-capped 120. Timing only the rAF callback would have said the blur costs 0.3 ms — the work is enqueued, not free, so the measurement has to force raster.
  • Radial alpha profile — the sprite's alpha is a six-stop ramp lerped between a soft gaussian-ish falloff and a rim-heavy one by edge, then normalised to peak 1 so intensity means the same brightness at any edge. The rim is what separates bokeh from a blurry dot: a real lens puts a bright ring at the aperture boundary.
  • Alpha-only mask — the tint is a raw theme token string handed to the canvas verbatim; the profile is applied afterwards with destination-in, whose operator discards the mask's colour channels. That is how the component gets a multi-stop gradient without ever parsing oklch() or hard-coding a colour.
  • Seeded disc field — every position, radius, alpha, phase and drift vector is an integer hash of (id, salt), so the same seed paints the same raster byte for byte, and growing the field after a resize keeps the discs already on screen exactly where they were.
  • devicePixelRatio, taking the maxdevice-pixel-content-box and devicePixelRatio each under-report in a different environment, so the backing store is sized by the larger of the two. Measured here: an emulated 2x page reports the device box in CSS pixels, and trusting it alone ships a 1x canvas.
  • Opacity budgetintensity is not a taste setting, it is the legibility budget. Measured worst pixel behind overlaid copy: text-foreground holds 7.65:1 light / 6.12:1 dark at the default 0.28 and drops through AA in dark somewhere just past 0.36.
  • Reduced-motion still frame — under prefers-reduced-motion: reduce the loop never starts, but the single painted frame is the full field with its breathing phases already spread apart, so it still reads as bokeh rather than a blank box. It is bit-identical to the speed={0} frame.

On This Page