{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bokeh-field",
  "type": "registry:ui",
  "title": "Bokeh Field",
  "description": "Out-of-focus lights drifting at different depths, with pointer parallax and a circle or hexagon aperture.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/bokeh-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type BokehPalette = \"ember\" | \"mint\" | \"dusk\" | \"mono\";\nexport type BokehAperture = \"circle\" | \"hexagon\";\n\nexport interface BokehFieldProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of light circles. */\n  count?: number;\n  /** Named colour palette. Ignored when `colors` is given. \"mono\" follows the text colour. */\n  palette?: BokehPalette;\n  /** Custom colours (any CSS colours); overrides `palette`. */\n  colors?: string[];\n  /** Drift speed multiplier. 0 holds the lights still. */\n  speed?: number;\n  /** Diameter of the nearest lights, in px. Far lights are about a third of this. */\n  size?: number;\n  /** How out of focus the near lights are, 0–1. */\n  blur?: number;\n  /** Lens aperture shape. */\n  aperture?: BokehAperture;\n  /** Shift the layers by depth as a mouse pointer moves. */\n  parallax?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\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\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Sprites ---------------------------------- */\n\nconst PALETTES: Record<Exclude<BokehPalette, \"mono\">, string[]> = {\n  ember: [\"#FF7448\", \"#FFB38A\", \"#F2D17B\"],\n  mint: [\"#B6F3D1\", \"#7FD8BE\", \"#E6FFF4\"],\n  dusk: [\"#FF7448\", \"#9DB4FF\", \"#E7A3C8\"],\n};\n\nconst SPRITE = 160; // sprite edge in px; lights are drawn scaled from these\nconst BLUR_LEVELS = 4;\nconst OFFSET = 4000; // shadow trick: the shape is drawn off-canvas, only its blurred shadow lands\n\nfunction shapePath(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, aperture: BokehAperture) {\n  ctx.beginPath();\n  if (aperture === \"hexagon\") {\n    for (let i = 0; i < 6; i++) {\n      const a = (Math.PI / 3) * i + Math.PI / 12; // slightly rotated blades read more like a lens\n      if (i === 0) ctx.moveTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);\n      else ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);\n    }\n    ctx.closePath();\n  } else {\n    ctx.arc(cx, cy, r, 0, Math.PI * 2);\n  }\n}\n\n/**\n * Pre-renders one blurred light per colour and blur level. The blur comes from `shadowBlur`\n * (supported everywhere, unlike `ctx.filter`) and is paid once here, never per frame.\n */\nfunction renderSprite(color: RGB, blurPx: number, aperture: BokehAperture) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = SPRITE;\n  canvas.height = SPRITE;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return canvas;\n  const r = SPRITE / 2 - blurPx * 1.5 - 2;\n  ctx.shadowColor = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;\n  ctx.shadowBlur = blurPx;\n  ctx.shadowOffsetX = OFFSET;\n  shapePath(ctx, SPRITE / 2 - OFFSET, SPRITE / 2, r, aperture);\n  ctx.fillStyle = \"rgba(0, 0, 0, 0.42)\";\n  ctx.fill();\n  // A brighter rim, like real out-of-focus highlights.\n  ctx.lineWidth = Math.max(1.5, r * 0.07);\n  ctx.strokeStyle = \"rgba(0, 0, 0, 0.75)\";\n  ctx.stroke();\n  return canvas;\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst PARALLAX_PX = 36;\n\n/**\n * Out-of-focus light circles drifting at different depths. Depth sets size, blur and opacity\n * (near lights are big, soft and faint; far lights small and crisper) and how far each light\n * moves with the pointer parallax. All blur is baked into sprites up front.\n */\nexport function BokehField({\n  count = 40,\n  palette = \"ember\",\n  colors,\n  speed = 1,\n  size = 120,\n  blur = 0.6,\n  aperture = \"circle\",\n  parallax = true,\n  className = \"\",\n  children,\n  ...props\n}: BokehFieldProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const colorKey = colors?.join(\"|\") ?? \"\";\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const total = Math.max(1, Math.round(count));\n    const maxR = Math.max(8, size / 2);\n    const softness = Math.min(1, Math.max(0, blur));\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let time = 0;\n    let inView = false;\n    let dark = true;\n    let sprites: HTMLCanvasElement[][] = []; // [colour][blur level]\n\n    // Lights sorted far → near so nearer ones draw on top.\n    const lights = Array.from({ length: total }, () => ({\n      x: 0,\n      y: 0,\n      depth: Math.random(),\n      tone: 0,\n      phase: Math.random() * Math.PI * 2,\n      wobble: 0.4 + Math.random() * 0.8,\n    })).sort((a, b) => a.depth - b.depth);\n\n    const ptr = { tx: 0, ty: 0, x: 0, y: 0 };\n\n    const buildSprites = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      dark = luminance(ink) > 0.5;\n      const list = colorKey ? colorKey.split(\"|\") : palette === \"mono\" ? [] : PALETTES[palette];\n      let rgbs = list.map((c) => resolveColor(c, [255, 116, 72]));\n      if (!rgbs.length) rgbs = [ink];\n      // On light themes, pale colours are deepened (more saturated, a little darker, same hue)\n      // so the circles stay visible without turning grey.\n      if (!dark) {\n        rgbs = rgbs.map((c) => {\n          if (luminance(c) <= 0.6) return c;\n          const mean = (c[0] + c[1] + c[2]) / 3;\n          return c.map((v) => Math.round(Math.min(255, Math.max(0, mean + (v - mean) * 1.4)) * 0.84)) as RGB;\n        });\n      }\n      sprites = rgbs.map((rgb) =>\n        Array.from({ length: BLUR_LEVELS }, (_, b) => renderSprite(rgb, 1.5 + (b / (BLUR_LEVELS - 1)) * softness * 18, aperture)),\n      );\n      lights.forEach((l, i) => (l.tone = i % sprites.length));\n    };\n\n    const radiusOf = (depth: number) => maxR * (0.32 + 0.68 * depth);\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n      for (const l of lights) {\n        const r = radiusOf(l.depth);\n        const twinkle = 0.82 + 0.18 * Math.sin(time * 0.8 * l.wobble + l.phase);\n        const base = dark ? 0.62 - 0.32 * l.depth : 0.55 - 0.28 * l.depth;\n        const x = l.x + ptr.x * l.depth;\n        const y = l.y + ptr.y * l.depth;\n        const level = Math.min(BLUR_LEVELS - 1, Math.round(l.depth * (BLUR_LEVELS - 1)));\n        ctx.globalAlpha = base * twinkle;\n        // The sprite's shape is inset from its edge, so scale it so the shape spans 2r.\n        const scale = (2 * r) / SPRITE / 0.82;\n        const s = SPRITE * scale;\n        ctx.drawImage(sprites[l.tone][level], x - s / 2, y - s / 2, s, s);\n      }\n      ctx.globalAlpha = 1;\n      ctx.globalCompositeOperation = \"source-over\";\n    };\n\n    const advance = (f: number) => {\n      for (const l of lights) {\n        const r = radiusOf(l.depth);\n        // Near lights drift faster: depth reads as motion parallax even without a pointer.\n        l.y -= (0.08 + 0.32 * l.depth) * speed * f;\n        l.x += Math.sin(time * 0.3 * l.wobble + l.phase) * 0.12 * speed * f;\n        if (l.y < -r * 1.4 - PARALLAX_PX) {\n          l.y = height + r * 1.4 + PARALLAX_PX;\n          l.x = Math.random() * width;\n        }\n        if (l.x < -r * 1.4 - PARALLAX_PX) l.x = width + r * 1.4;\n        else if (l.x > width + r * 1.4 + PARALLAX_PX) l.x = -r * 1.4;\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      const f = dt / 16.7;\n      time += (dt / 1000) * speed;\n      advance(f);\n      const ease = 1 - Math.pow(0.94, f);\n      ptr.x += (ptr.tx - ptr.x) * ease;\n      ptr.y += (ptr.ty - ptr.y) * ease;\n      draw();\n      const settled = Math.abs(ptr.tx - ptr.x) < 0.05 && Math.abs(ptr.ty - ptr.y) < 0.05;\n      if (speed > 0 || !settled) schedule();\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const scatter = () => {\n      for (const l of lights) {\n        l.x = Math.random() * width;\n        l.y = Math.random() * height;\n      }\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;\n      const first = width === 0;\n      const sx = width ? rect.width / width : 1;\n      const sy = height ? rect.height / height : 1;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      if (first) scatter();\n      else\n        for (const l of lights) {\n          l.x *= sx;\n          l.y *= sy;\n        }\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      buildSprites();\n      if (!raf) draw();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return;\n      const rect = canvas.getBoundingClientRect();\n      const nx = (e.clientX - rect.left) / rect.width - 0.5;\n      const ny = (e.clientY - rect.top) / rect.height - 0.5;\n      const inside = Math.abs(nx) <= 0.5 && Math.abs(ny) <= 0.5;\n      // Lights move against the pointer, as if the camera pans toward it.\n      ptr.tx = inside ? -nx * 2 * PARALLAX_PX : 0;\n      ptr.ty = inside ? -ny * 2 * PARALLAX_PX : 0;\n      schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.tx = 0;\n      ptr.ty = 0;\n      schedule();\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const listenPointer = parallax && !reduceMotion;\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (listenPointer) {\n          window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        }\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        stop();\n      }\n    });\n\n    buildSprites();\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [count, palette, colorKey, speed, size, blur, aperture, parallax, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/bokeh-field"
}
