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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/bokeh-lights.jsonPrompt
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 sointensitymeans the same brightness at anyedge. 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 parsingoklch()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 sameseedpaints 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 max —
device-pixel-content-boxanddevicePixelRatioeach 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 budget —
intensityis not a taste setting, it is the legibility budget. Measured worst pixel behind overlaid copy:text-foregroundholds 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: reducethe 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 thespeed={0}frame.
Voronoi Cells
A slowly flowing Voronoi diagram on one canvas — organic cell walls, tinted mosaic or separated pebbles, seeded so the same field comes back every time.
Scanlines
A CRT line grille whose pitch is snapped to the display's physical pixel grid, with an optional one-pixel-per-step roll and a WCAG-clamped flicker.