{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bokeh-lights",
  "title": "Bokeh Lights",
  "description": "Slowly floating out-of-focus light discs on one canvas — seeded for a reproducible picture, painted from a pre-rendered sprite instead of a per-frame blur, with an opacity lever that keeps overlaid copy readable.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/bokeh-lights.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nconst TAU = Math.PI * 2\n/** A backgrounded tab resumes with a huge gap — clamp so nothing teleports. */\nconst MAX_DT = 1 / 30\n/** 3x backing stores cost 2.25x the fill for no visible gain on an out-of-focus disc. */\nconst MAX_DPR = 2\n/** The only thing between a typo'd `count` and a frozen tab. */\nconst MAX_LIGHTS = 160\n/** A light needs this much canvas to itself; small cards get proportionally fewer. */\nconst DEFAULT_DENSITY = 14000\n/**\n * Sprite radius ceiling in device px. A 256px-radius sprite is a 512x512 RGBA\n * buffer (1MB); past that the extra detail is invisible on a blurred disc, and\n * upscaling a sprite that is already a smooth gradient costs nothing visually.\n */\nconst MAX_SPRITE_RADIUS = 256\n\nconst DEFAULT_COLORS = [\"chart-1\", \"chart-3\", \"chart-5\"]\nconst DEFAULT_SIZE: [number, number] = [30, 110]\n\n/** Radii (as a fraction of the disc) at which the alpha profile is sampled. */\nconst PROFILE_STOPS = [0, 0.3, 0.55, 0.78, 0.9, 1]\n/** `edge: 0` — a plain out-of-focus blob, roughly gaussian. */\nconst PROFILE_SOFT = [1, 0.78, 0.42, 0.17, 0.07, 0]\n/** `edge: 1` — the bright rim a real lens leaves around an out-of-focus highlight. */\nconst PROFILE_RING = [0.22, 0.28, 0.55, 1, 0.4, 0]\n\n/** What Chromium/WebKit/Gecko normalise `fillStyle = \"transparent\"` to. */\nconst UNRESOLVED = \"rgba(0, 0, 0, 0)\"\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t\nconst finite = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback)\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))\n\n/**\n * Deterministic integer hash -> [0, 1). Every property of every light is\n * addressed by (id, salt) instead of drawn from a stream, so light #5 is the\n * same light no matter how many exist around it: growing the field after a\n * resize never reshuffles the discs already on screen, and the same `seed`\n * always paints the same picture. Math.random() is never called — not during\n * render (purity / SSR) and not in the loop (screenshots must be byte-stable).\n */\nfunction hash(a: number, b: number) {\n  let x = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x165667b1, 0xc2b2ae35)\n  x = Math.imul(x ^ (x >>> 15), 0x2545f491)\n  return ((x ^ (x >>> 13)) >>> 0) / 4294967296\n}\n\n/**\n * Paint one bokeh sprite: a disc of `color` whose alpha follows the\n * soft/ring profile. Returns false when neither the token nor the fallback\n * resolved to a colour the canvas understands.\n *\n * This is the whole performance story of the component. The alternative —\n * `ctx.filter = \"blur(Npx)\"` around a solid arc — re-runs a gaussian blur for\n * every disc on every frame; the sprite is rasterised once per (colour, size,\n * dpr) and every frame afterwards is a plain textured blit.\n */\nfunction paintSprite(sprite: HTMLCanvasElement, radius: number, color: string, fallback: string, edge: number) {\n  const size = radius * 2\n  sprite.width = size\n  sprite.height = size\n  const ctx = sprite.getContext(\"2d\")\n  if (!ctx) return false\n\n  ctx.clearRect(0, 0, size, size)\n  // An unparsable string leaves fillStyle untouched, so start from a sentinel:\n  // the readback below then reads \"this token did not resolve\" instead of\n  // silently painting the default opaque black.\n  ctx.fillStyle = \"transparent\"\n  ctx.fillStyle = color\n  if (ctx.fillStyle === UNRESOLVED) ctx.fillStyle = fallback\n  if (ctx.fillStyle === UNRESOLVED) return false\n  ctx.fillRect(0, 0, size, size)\n\n  const profile = PROFILE_STOPS.map((_, i) => lerp(PROFILE_SOFT[i], PROFILE_RING[i], edge))\n  // Normalised so `intensity` means the same brightness at every `edge`.\n  const peak = Math.max(...profile) || 1\n\n  // destination-in keeps the tint and replaces its alpha with the profile. The\n  // mask's RGB channels are discarded by the operator, so the literal below is\n  // not a colour — it is an alpha ramp, and the visible ink stays 100% token.\n  ctx.globalCompositeOperation = \"destination-in\"\n  const gradient = ctx.createRadialGradient(radius, radius, 0, radius, radius, radius)\n  for (let i = 0; i < PROFILE_STOPS.length; i++) {\n    gradient.addColorStop(PROFILE_STOPS[i], `rgba(0,0,0,${profile[i] / peak})`)\n  }\n  ctx.fillStyle = gradient\n  ctx.fillRect(0, 0, size, size)\n  ctx.globalCompositeOperation = \"source-over\"\n  return true\n}\n\ninterface Light {\n  /** Stable identity for the hash — survives resizes and field growth. */\n  id: number\n  /** Centre in CSS px, relative to the canvas box. */\n  x: number\n  y: number\n  /** Drift velocity in CSS px/s. */\n  vx: number\n  vy: number\n  /** Disc radius in CSS px. */\n  r: number\n  /** Per-light brightness multiplier on `intensity`. */\n  alpha: number\n  /** Offset so no two lights breathe or sway in lockstep. */\n  phase: number\n  /** Sideways sway amplitude in CSS px/s, and its rate in rad/s. */\n  sway: number\n  swayRate: number\n  /** Brightness oscillation rate in rad/s. */\n  breathRate: number\n  /** Index into the resolved palette. */\n  tint: number\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  mq.addEventListener(\"change\", callback)\n  return () => mq.removeEventListener(\"change\", callback)\n}\n\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  )\n}\n\nexport interface BokehLightsProps extends React.ComponentProps<\"div\"> {\n  /** Requested disc count. The effective count is also capped by area — see `density`. */\n  count?: number\n  /** `[min, max]` disc radius in CSS px; each light picks one deterministically. */\n  size?: [number, number]\n  /** Multiplier on the whole simulation (drift, sway, breathing). `0` freezes the field and the rAF loop never starts. */\n  speed?: number\n  /** Integer seed for the field. Same seed, same picture — byte for byte. */\n  seed?: number\n  /** Peak opacity of a disc, `0..1`. This is the knob that keeps overlaid text readable. */\n  intensity?: number\n  /** `0` = a soft out-of-focus blob, `1` = a bright lens rim around a dimmer core. */\n  edge?: number\n  /** Theme token names (without the leading `--`), cycled across the lights. */\n  colors?: string[]\n  /** CSS px² of canvas required per light — the area cap on `count`. Lower = denser. */\n  density?: number\n}\n\n/**\n * BokehLights — slowly floating out-of-focus light discs, for hero and card\n * backdrops. Children render above the canvas; the canvas itself is inert\n * (`aria-hidden`, `pointer-events-none`, not focusable) so it never intercepts\n * a click or a Tab stop.\n *\n * The loop is suspended whenever the container scrolls off screen or the tab is\n * hidden, and under `prefers-reduced-motion: reduce` it paints exactly one\n * static frame — the discs are still there, they just stop moving.\n */\nexport function BokehLights({\n  count = 22,\n  size = DEFAULT_SIZE,\n  speed = 1,\n  seed = 5,\n  intensity = 0.28,\n  edge = 0.6,\n  colors = DEFAULT_COLORS,\n  density = DEFAULT_DENSITY,\n  className,\n  children,\n  ...props\n}: BokehLightsProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const reduced = useReducedMotion()\n\n  // Clamp every numeric prop up front and treat non-finite values as the\n  // default: a NaN count empties the field, density 0 asks for an infinite one,\n  // a negative speed runs the drift backwards, and a reversed size tuple hands\n  // Math a negative range.\n  const safeCount = clamp(Math.floor(finite(count, 22)), 0, MAX_LIGHTS)\n  const safeDensity = Math.max(400, finite(density, DEFAULT_DENSITY))\n  const safeSpeed = Math.max(0, finite(speed, 1))\n  const safeSeed = Math.trunc(finite(seed, 5)) | 0\n  const safeIntensity = clamp(finite(intensity, 0.28), 0, 1)\n  const safeEdge = clamp(finite(edge, 0.6), 0, 1)\n  const minRadius = Math.max(2, Math.min(finite(size[0], 30), finite(size[1], 110)))\n  const maxRadius = Math.max(minRadius, Math.min(600, Math.max(finite(size[0], 30), finite(size[1], 110))))\n  // The effect must not re-run because a caller inlined the array literal.\n  const paletteKey = (colors.length > 0 ? colors : DEFAULT_COLORS).join(\"|\")\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    const palette = paletteKey.split(\"|\")\n    const lights: Light[] = []\n    const sprites: HTMLCanvasElement[] = palette.map(() => document.createElement(\"canvas\"))\n\n    let width = 0\n    let height = 0\n    let appliedW = 0\n    let appliedH = 0\n    let appliedDpr = 0\n    let spriteRadius = 0\n    let spriteSignature = \"\"\n    let spritesReady = false\n    let rafId: number | null = null\n    let lastFrame = 0\n    /** Simulated seconds; advances by dt * speed, so `speed` scales the whole field. */\n    let elapsed = 0\n    let onScreen = true\n    let pageVisible = document.visibilityState === \"visible\"\n\n    const createLight = (index: number): Light => {\n      const id = (safeSeed ^ Math.imul(index + 1, 0x9e3779b1)) | 0\n      const heading = -Math.PI / 2 + (hash(id, 3) - 0.5) * 1.7\n      const magnitude = 3 + hash(id, 4) * 9\n      return {\n        id,\n        x: hash(id, 1) * width,\n        y: hash(id, 2) * height,\n        vx: Math.cos(heading) * magnitude,\n        vy: Math.sin(heading) * magnitude,\n        // Squared so small discs outnumber big ones, which is what depth of\n        // field actually does — a field of uniformly large discs reads as soup.\n        r: minRadius + (maxRadius - minRadius) * hash(id, 5) ** 2,\n        alpha: 0.55 + hash(id, 6) * 0.45,\n        phase: hash(id, 7) * TAU,\n        sway: 3 + hash(id, 8) * 9,\n        swayRate: 0.18 + hash(id, 9) * 0.3,\n        breathRate: 0.25 + hash(id, 10) * 0.45,\n        tint: Math.min(palette.length - 1, Math.floor(hash(id, 11) * palette.length)),\n      }\n    }\n\n    /** Grow/shrink the field to the area-capped target without reseeding it. */\n    const retarget = () => {\n      const areaCap = Math.max(1, Math.floor((width * height) / safeDensity))\n      const target = Math.min(safeCount, areaCap)\n      if (lights.length > target) lights.length = target\n      while (lights.length < target) lights.push(createLight(lights.length))\n    }\n\n    /**\n     * Resolve the palette through the canvas's own computed style, so the field\n     * follows the host theme (and dark mode) for free. Custom properties are\n     * read as authored (`oklch(...)`, `color-mix(...)`, a brand colour the\n     * consumer put behind the token) and handed to the canvas verbatim —\n     * nothing here parses or hard-codes a colour.\n     */\n    const buildSprites = () => {\n      const styles = getComputedStyle(canvas)\n      const fallback = styles.color\n      const resolved = palette.map(token => styles.getPropertyValue(`--${token}`).trim())\n      const signature = `${resolved.join(\"|\")}|${fallback}|${spriteRadius}`\n      if (signature === spriteSignature) return false\n      spriteSignature = signature\n      spritesReady = spriteRadius > 0\n      for (let i = 0; i < sprites.length; i++) {\n        if (!spriteRadius || !paintSprite(sprites[i], spriteRadius, resolved[i], fallback, safeEdge)) {\n          spritesReady = false\n        }\n      }\n      return true\n    }\n\n    const step = (ds: number, t: number) => {\n      for (const light of lights) {\n        light.x += (light.vx + Math.sin(t * light.swayRate + light.phase) * light.sway) * ds\n        light.y += light.vy * ds\n        // Wrap on the full disc so a light never pops in or out at an edge.\n        const margin = light.r + 2\n        if (light.x < -margin) light.x = width + margin\n        else if (light.x > width + margin) light.x = -margin\n        if (light.y < -margin) light.y = height + margin\n        else if (light.y > height + margin) light.y = -margin\n      }\n    }\n\n    const draw = (t: number) => {\n      ctx.clearRect(0, 0, width, height)\n      if (!spritesReady) return\n      for (const light of lights) {\n        const breath = 0.78 + 0.22 * Math.sin(t * light.breathRate + light.phase)\n        const alpha = safeIntensity * light.alpha * breath\n        if (alpha <= 0.002) continue\n        // A hair of scale pulsing sells \"floating\" far better than opacity\n        // alone, and costs nothing: it is two numbers in the same blit.\n        const r = light.r * (1 + 0.05 * Math.sin(t * light.breathRate * 0.7 + light.phase * 1.7))\n        ctx.globalAlpha = alpha\n        ctx.drawImage(sprites[light.tint], light.x - r, light.y - r, r * 2, r * 2)\n      }\n      ctx.globalAlpha = 1\n    }\n\n    const frame = (now: number) => {\n      rafId = requestAnimationFrame(frame)\n      const dt = lastFrame === 0 ? 1 / 60 : Math.min((now - lastFrame) / 1000, MAX_DT)\n      lastFrame = now\n      const ds = dt * safeSpeed\n      elapsed += ds\n      step(ds, elapsed)\n      draw(elapsed)\n    }\n\n    const stop = () => {\n      if (rafId === null) return\n      cancelAnimationFrame(rafId)\n      rafId = null\n    }\n\n    /** Nothing moves at speed 0, so there is nothing for a loop to do. */\n    const animated = safeSpeed > 0\n\n    /** No-ops unless the field is on screen, the tab is visible and motion is allowed. */\n    const start = () => {\n      if (rafId !== null || reduced || !animated || !onScreen || !pageVisible || width <= 0) return\n      lastFrame = 0\n      rafId = requestAnimationFrame(frame)\n    }\n\n    /**\n     * @param deviceW backing-store width in device px when the browser reports\n     *        it. Neither source is trustworthy alone: emulated surfaces report\n     *        `devicePixelContentBoxSize` in CSS px while rendering at 2x, and\n     *        real hi-dpi windows have been measured reporting\n     *        `devicePixelRatio: 1` with a truthful device box. Taking the max\n     *        of the two is the only reading that is sharp in both.\n     */\n    const resize = (cssW: number, cssH: number, deviceW?: number, deviceH?: number) => {\n      if (cssW <= 0 || cssH <= 0) return\n      const reportedDpr = deviceW && deviceW > 0 ? deviceW / cssW : 0\n      const dpr = clamp(Math.max(finite(window.devicePixelRatio, 1), reportedDpr), 1, MAX_DPR)\n      // Writing canvas.width never changes the CSS box, so this cannot loop —\n      // but bailing on an unchanged box also skips a full reseed + sprite\n      // repaint when the observer fires for an unrelated reason.\n      if (cssW === appliedW && cssH === appliedH && dpr === appliedDpr) return\n\n      const backingW = deviceW && Math.abs(deviceW / cssW - dpr) < 0.01 ? deviceW : Math.round(cssW * dpr)\n      const backingH = deviceH && Math.abs(deviceH / cssH - dpr) < 0.01 ? deviceH : Math.round(cssH * dpr)\n      canvas.width = backingW\n      canvas.height = backingH\n      // Writing the backing store resets the context, so the transform is\n      // (re)applied here — everything below draws in CSS px. Deriving the scale\n      // from the real backing size keeps the mapping exact after the rounding.\n      ctx.setTransform(backingW / cssW, 0, 0, backingH / cssH, 0, 0)\n\n      if (width > 0 && height > 0) {\n        // Rescale in place instead of reseeding: opening a sidebar must not\n        // reshuffle the field.\n        const sx = cssW / width\n        const sy = cssH / height\n        for (const light of lights) {\n          light.x *= sx\n          light.y *= sy\n        }\n      }\n      appliedW = cssW\n      appliedH = cssH\n      appliedDpr = dpr\n      width = cssW\n      height = cssH\n\n      spriteRadius = clamp(Math.ceil(maxRadius * dpr), 8, MAX_SPRITE_RADIUS)\n      retarget()\n      buildSprites()\n      // Paints the reduced-motion still frame, and keeps a frozen field from\n      // showing an empty canvas after a resize.\n      if (rafId === null) draw(elapsed)\n      start()\n    }\n\n    let resizeObserver: ResizeObserver | null = null\n    if (typeof ResizeObserver === \"undefined\") {\n      resize(canvas.clientWidth, canvas.clientHeight)\n    } else {\n      // observe() fires once immediately — that callback is the initial sizing.\n      resizeObserver = new ResizeObserver(entries => {\n        const entry = entries[entries.length - 1]\n        if (!entry) return\n        const device = entry.devicePixelContentBoxSize?.[0]\n        resize(entry.contentRect.width, entry.contentRect.height, device?.inlineSize, device?.blockSize)\n      })\n      try {\n        // Browsers that do not know this box throw a WebIDL TypeError from\n        // observe() rather than ignoring the option.\n        resizeObserver.observe(canvas, { box: \"device-pixel-content-box\" })\n      } catch {\n        resizeObserver.observe(canvas)\n      }\n    }\n\n    let intersectionObserver: IntersectionObserver | null = null\n    if (typeof IntersectionObserver !== \"undefined\") {\n      intersectionObserver = new IntersectionObserver(entries => {\n        const entry = entries[entries.length - 1]\n        if (!entry) return\n        onScreen = entry.isIntersecting\n        if (onScreen) start()\n        else stop()\n      })\n      intersectionObserver.observe(canvas)\n    }\n\n    const handleVisibility = () => {\n      pageVisible = document.visibilityState === \"visible\"\n      if (pageVisible) start()\n      else stop()\n    }\n    document.addEventListener(\"visibilitychange\", handleVisibility)\n\n    // Theme flips land as a class/style change on <html>. Custom properties are\n    // not animatable, so the new token values are readable immediately — no\n    // settle delay needed, unlike a `transition-colors` background.\n    const themeObserver = new MutationObserver(() => {\n      if (buildSprites() && rafId === null) draw(elapsed)\n    })\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    return () => {\n      stop()\n      resizeObserver?.disconnect()\n      intersectionObserver?.disconnect()\n      themeObserver.disconnect()\n      document.removeEventListener(\"visibilitychange\", handleVisibility)\n      // Drop the sprite buffers: at dpr 2 with the default 110px max radius\n      // these are three 440x440 RGBA surfaces, and a detached canvas keeps its\n      // backing store. Order matters — stop() first, because a loop that\n      // reaches a 0x0 sprite throws InvalidStateError from drawImage.\n      for (const sprite of sprites) {\n        sprite.width = 0\n        sprite.height = 0\n      }\n    }\n  }, [\n    safeCount,\n    safeDensity,\n    safeSpeed,\n    safeSeed,\n    safeIntensity,\n    safeEdge,\n    minRadius,\n    maxRadius,\n    paletteKey,\n    reduced,\n  ])\n\n  return (\n    <div className={cn(\"relative isolate overflow-hidden\", className)} {...props}>\n      <canvas\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block size-full\"\n        ref={canvasRef}\n      />\n\n      <div className=\"relative z-10\">{children}</div>\n    </div>\n  )\n}\n\nexport default BokehLights\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}