{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voronoi-field",
  "type": "registry:ui",
  "title": "Voronoi Field",
  "description": "A drifting Voronoi mosaic with hairline cell edges — the pointer becomes a seed and the cells make room.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/voronoi-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type VoronoiFill = \"none\" | \"tint\" | \"distance\";\nexport type VoronoiPalette = \"ink\" | \"clay\" | \"sea\" | \"accent\";\n\nexport interface VoronoiFieldProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of drifting seed points (8–120). */\n  count?: number;\n  /** Drift speed multiplier; 0 holds the mosaic still (the pointer seed still works). */\n  speed?: number;\n  /** Cell edge width, in px. */\n  lineWidth?: number;\n  /** Cell fills: none, a flat tint per cell, or a radial falloff from each seed. */\n  fill?: VoronoiFill;\n  /** Fill palette. `ink` follows the theme, `accent` uses the accent colour. */\n  palette?: VoronoiPalette;\n  /** The pointer becomes a seed with its own cell, and nearby seeds make room for it. */\n  pointer?: boolean;\n  /** Draw a small dot at every seed. */\n  showSeeds?: boolean;\n  /** Colour of the pointer's cell and the edges around it (any CSS colour). */\n  accent?: string;\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/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.45)) as RGB;\n}\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/* ------------------------------ Triangulation ------------------------------ */\n\ninterface Triangle {\n  a: number;\n  b: number;\n  c: number;\n  /** Circumcentre — a Voronoi vertex. */\n  cx: number;\n  cy: number;\n  /** Squared circumradius. */\n  r2: number;\n}\n\nfunction makeTriangle(xs: Float64Array, ys: Float64Array, a: number, b: number, c: number): Triangle {\n  const ax = xs[a];\n  const ay = ys[a];\n  const bx = xs[b] - ax;\n  const by = ys[b] - ay;\n  const cx = xs[c] - ax;\n  const cy = ys[c] - ay;\n  const d = 2 * (bx * cy - by * cx) || 1e-9;\n  const b2 = bx * bx + by * by;\n  const c2 = cx * cx + cy * cy;\n  const ux = (cy * b2 - by * c2) / d;\n  const uy = (bx * c2 - cx * b2) / d;\n  return { a, b, c, cx: ax + ux, cy: ay + uy, r2: ux * ux + uy * uy };\n}\n\n/**\n * Bowyer–Watson Delaunay triangulation. Points are inserted one at a time into a huge super\n * triangle: every triangle whose circumcircle contains the new point is removed, and the hole's\n * boundary edges are re-joined to the point. O(n²) — plenty for a few hundred points per frame.\n * `xs`/`ys` must have three spare slots at the end for the super triangle.\n */\nfunction triangulate(xs: Float64Array, ys: Float64Array, count: number, span: number): Triangle[] {\n  const s = count;\n  xs[s] = -span * 10;\n  ys[s] = -span * 10;\n  xs[s + 1] = span * 20;\n  ys[s + 1] = -span * 10;\n  xs[s + 2] = span * 5;\n  ys[s + 2] = span * 20;\n  let tris: Triangle[] = [makeTriangle(xs, ys, s, s + 1, s + 2)];\n  const edges = new Map<number, number>();\n\n  for (let p = 0; p < count; p++) {\n    const px = xs[p];\n    const py = ys[p];\n    edges.clear();\n    const keep: Triangle[] = [];\n    for (const t of tris) {\n      const dx = px - t.cx;\n      const dy = py - t.cy;\n      if (dx * dx + dy * dy < t.r2) {\n        // Bad triangle: record its edges; edges seen twice are interior to the hole.\n        for (const [u, v] of [\n          [t.a, t.b],\n          [t.b, t.c],\n          [t.c, t.a],\n        ]) {\n          const key = u < v ? u * 4096 + v : v * 4096 + u;\n          edges.set(key, (edges.get(key) ?? 0) + 1);\n        }\n      } else keep.push(t);\n    }\n    for (const [key, seen] of edges) {\n      if (seen === 1) keep.push(makeTriangle(xs, ys, Math.floor(key / 4096), key % 4096, p));\n    }\n    tris = keep;\n  }\n  return tris;\n}\n\n/* --------------------------------- Helpers --------------------------------- */\n\nconst hash = (n: number) => {\n  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n  return s - Math.floor(s);\n};\n\nconst PALETTES: Record<Exclude<VoronoiPalette, \"ink\" | \"accent\">, string[]> = {\n  clay: [\"#C8553D\", \"#E09F6B\", \"#D8C3A5\", \"#8C5A3C\", \"#F0C987\"],\n  sea: [\"#1F6F78\", \"#3FA7A3\", \"#B6F3D1\", \"#5C8EA8\", \"#8FC9B9\"],\n};\n\nconst GHOSTS = 10; // far points on a ring, so every real cell is a closed polygon\nconst STILL_TIME = 4; // seconds of drift used for the reduced-motion still frame\n\n/**\n * An animated Voronoi mosaic on one canvas. Seeds drift and bounce inside the container; every\n * frame they are triangulated with Bowyer–Watson (plus a far ring of ghost points so edge cells\n * close), and each cell is the polygon of circumcentres around its seed. All edges are stroked as\n * a single path, so shared edges never double up. The pointer becomes a seed with its own accent\n * cell, nearby seeds are pushed aside, and edges near it glow in the accent colour.\n */\nexport function VoronoiField({\n  count = 36,\n  speed = 0.5,\n  lineWidth = 1,\n  fill = \"distance\",\n  palette = \"clay\",\n  pointer = true,\n  showSeeds = true,\n  accent = \"#FF7448\",\n  className = \"\",\n  children,\n  ...props\n}: VoronoiFieldProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const n = Math.max(8, Math.min(120, Math.round(count)));\n    const drift = Math.max(0, speed);\n    const interactive = pointer && !reduceMotion;\n    const total = n + 1 + GHOSTS; // seeds, pointer slot, ghosts\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 ink: RGB = [24, 24, 27];\n    let accentRGB: RGB = [255, 116, 72];\n    let colours: RGB[] = [];\n\n    // Seed state: position, heading and a decaying push from the pointer.\n    const sx = new Float64Array(n);\n    const sy = new Float64Array(n);\n    const heading = Float64Array.from({ length: n }, (_, i) => hash(i + 3) * Math.PI * 2);\n    const pace = Float64Array.from({ length: n }, (_, i) => 0.6 + hash(i + 9) * 0.8);\n    const pushX = new Float64Array(n);\n    const pushY = new Float64Array(n);\n    // Triangulation input (+3 slots for the super triangle).\n    const xs = new Float64Array(total + 3);\n    const ys = new Float64Array(total + 3);\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, touch: false, strength: 0 };\n\n    const readTheme = () => {\n      ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);\n      colours =\n        palette === \"ink\"\n          ? [ink]\n          : palette === \"accent\"\n            ? [accentRGB]\n            : PALETTES[palette].map((c) => legible(resolveColor(c, ink), ink));\n    };\n\n    /** Low-discrepancy (R2 sequence) start positions: even coverage without randomness. */\n    const seed = () => {\n      for (let i = 0; i < n; i++) {\n        sx[i] = ((0.5 + (i + 1) * 0.7548776662) % 1) * width;\n        sy[i] = ((0.5 + (i + 1) * 0.5698402910) % 1) * height;\n      }\n    };\n\n    const step = (dt: number) => {\n      time += dt;\n      const cellSize = Math.sqrt((width * height) / n);\n      const reach = cellSize * 1.4;\n      const cruise = cellSize * 0.18 * drift; // px per second\n      for (let i = 0; i < n; i++) {\n        // Gentle wander: each heading turns slowly back and forth.\n        heading[i] += Math.sin(time * 0.35 + i * 1.7) * dt * 0.5;\n        let vx = Math.cos(heading[i]) * cruise * pace[i];\n        let vy = Math.sin(heading[i]) * cruise * pace[i];\n        if (ptr.strength > 0.01) {\n          const dx = sx[i] - ptr.x;\n          const dy = sy[i] - ptr.y;\n          const d = Math.sqrt(dx * dx + dy * dy) || 1;\n          if (d < reach) {\n            const f = (1 - d / reach) ** 2 * ptr.strength * cellSize * 6;\n            pushX[i] += (dx / d) * f * dt;\n            pushY[i] += (dy / d) * f * dt;\n          }\n        }\n        const decay = Math.exp(-dt * 2.5);\n        pushX[i] *= decay;\n        pushY[i] *= decay;\n        vx += pushX[i];\n        vy += pushY[i];\n        sx[i] += vx * dt;\n        sy[i] += vy * dt;\n        // Bounce off the walls by turning the heading around.\n        if (sx[i] < 0 || sx[i] > width) {\n          sx[i] = Math.min(width, Math.max(0, sx[i]));\n          heading[i] = Math.PI - heading[i];\n          pushX[i] *= -0.5;\n        }\n        if (sy[i] < 0 || sy[i] > height) {\n          sy[i] = Math.min(height, Math.max(0, sy[i]));\n          heading[i] = -heading[i];\n          pushY[i] *= -0.5;\n        }\n      }\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      if (!width || !height) return;\n\n      // Assemble points: seeds, the pointer seed (if active), then the ghost ring.\n      let m = 0;\n      for (let i = 0; i < n; i++) {\n        xs[m] = sx[i];\n        ys[m] = sy[i];\n        m++;\n      }\n      const pointerIndex = ptr.strength > 0.05 ? m : -1;\n      if (pointerIndex >= 0) {\n        xs[m] = ptr.x;\n        ys[m] = ptr.y;\n        m++;\n      }\n      const real = m;\n      const span = Math.max(width, height);\n      for (let g = 0; g < GHOSTS; g++) {\n        const a = (g / GHOSTS) * Math.PI * 2;\n        xs[m] = width / 2 + Math.cos(a) * span * 2.5;\n        ys[m] = height / 2 + Math.sin(a) * span * 2.5;\n        m++;\n      }\n      const tris = triangulate(xs, ys, m, span);\n\n      // Each real point's cell = circumcentres of its triangles, sorted by angle around it.\n      const around: number[][] = Array.from({ length: real }, () => []);\n      tris.forEach((t, k) => {\n        if (t.a < real) around[t.a].push(k);\n        if (t.b < real) around[t.b].push(k);\n        if (t.c < real) around[t.c].push(k);\n      });\n      const cellSize = Math.sqrt((width * height) / n);\n      const dark = luminance(ink) > 0.5;\n      const edges = new Path2D();\n      let pointerCell: Path2D | null = null;\n\n      for (let i = 0; i < real; i++) {\n        const list = around[i];\n        if (list.length < 3) continue;\n        const px = xs[i];\n        const py = ys[i];\n        list.sort((p, q) => Math.atan2(tris[p].cy - py, tris[p].cx - px) - Math.atan2(tris[q].cy - py, tris[q].cx - px));\n        const poly = new Path2D();\n        list.forEach((k, j) => (j === 0 ? poly.moveTo(tris[k].cx, tris[k].cy) : poly.lineTo(tris[k].cx, tris[k].cy)));\n        poly.closePath();\n        edges.addPath(poly);\n\n        if (i === pointerIndex) {\n          pointerCell = poly;\n          continue;\n        }\n        if (fill === \"none\") continue;\n        const [r, g, b] = colours[Math.floor(hash(i + 17) * colours.length)];\n        // Colours read muddier on dark grounds, so they get a little more opacity there.\n        const base = palette === \"ink\" ? 0.05 + hash(i + 29) * 0.09 : (0.14 + hash(i + 29) * 0.3) * (dark ? 1.35 : 1);\n        let near = 0;\n        if (ptr.strength > 0.01) {\n          const d = Math.hypot(px - ptr.x, py - ptr.y) / (cellSize * 2.6);\n          near = d < 1 ? (1 - d) * (1 - d) * ptr.strength : 0;\n        }\n        if (fill === \"tint\") {\n          ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${Math.min(0.85, base * (1 + near * 1.4)).toFixed(3)})`;\n        } else {\n          // Distance: a radial falloff from the seed toward the cell's far corners.\n          const grad = ctx.createRadialGradient(px, py, 0, px, py, cellSize * 1.15);\n          grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${Math.min(0.9, (base + 0.12) * (1 + near * 1.6)).toFixed(3)})`);\n          grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);\n          ctx.fillStyle = grad;\n        }\n        ctx.fill(poly);\n      }\n\n      const [ir, ig, ib] = ink;\n      const [ar, ag, ab] = accentRGB;\n      ctx.lineJoin = \"round\";\n      ctx.lineWidth = lineWidth;\n      ctx.strokeStyle = `rgba(${ir}, ${ig}, ${ib}, ${dark ? 0.24 : 0.22})`;\n      ctx.stroke(edges);\n\n      if (ptr.strength > 0.02) {\n        // Edges near the pointer pick up the accent.\n        const glow = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, cellSize * 2.4);\n        glow.addColorStop(0, `rgba(${ar}, ${ag}, ${ab}, ${(0.9 * ptr.strength).toFixed(3)})`);\n        glow.addColorStop(1, `rgba(${ar}, ${ag}, ${ab}, 0)`);\n        ctx.strokeStyle = glow;\n        ctx.stroke(edges);\n      }\n      if (pointerCell) {\n        ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${(0.16 * ptr.strength).toFixed(3)})`;\n        ctx.fill(pointerCell);\n        ctx.lineWidth = lineWidth + 0.75;\n        ctx.strokeStyle = `rgba(${ar}, ${ag}, ${ab}, ${ptr.strength.toFixed(3)})`;\n        ctx.stroke(pointerCell);\n      }\n\n      if (showSeeds) {\n        const dots = new Path2D();\n        const r = Math.max(1, lineWidth * 1.1);\n        for (let i = 0; i < n; i++) {\n          dots.moveTo(sx[i] + r, sy[i]);\n          dots.arc(sx[i], sy[i], r, 0, Math.PI * 2);\n        }\n        ctx.fillStyle = `rgba(${ir}, ${ig}, ${ib}, ${dark ? 0.5 : 0.45})`;\n        ctx.fill(dots);\n      }\n      if (pointerIndex >= 0) {\n        ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${ptr.strength.toFixed(3)})`;\n        ctx.beginPath();\n        ctx.arc(ptr.x, ptr.y, 2.5, 0, Math.PI * 2);\n        ctx.fill();\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(0.064, last ? (now - last) / 1000 : 1 / 60);\n      last = now;\n      if (ptr.inside) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.x = ptr.clientX - rect.left;\n        ptr.y = ptr.clientY - rect.top;\n      }\n      ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.004, dt));\n      if (!ptr.inside && ptr.strength < 0.005) ptr.strength = 0;\n      step(dt);\n      draw();\n      const pushing = pushX.some((v) => Math.abs(v) > 0.05) || pushY.some((v) => Math.abs(v) > 0.05);\n      if (drift > 0 || ptr.inside || ptr.strength > 0 || pushing) 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 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 || height === 0;\n      const kx = first ? 1 : rect.width / width;\n      const ky = first ? 1 : rect.height / height;\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) {\n        seed();\n        if (reduceMotion) {\n          // A still frame from a few seconds into the drift reads less like a grid.\n          for (let k = 0; k < STILL_TIME * 30; k++) step(1 / 30);\n        }\n      } else {\n        for (let i = 0; i < n; i++) {\n          sx[i] *= kx;\n          sy[i] *= ky;\n        }\n      }\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      if (!raf) draw();\n    };\n\n    const locate = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\" && !ptr.touch) return;\n      ptr.inside = locate(e);\n      if (ptr.inside) schedule();\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = true;\n      ptr.inside = locate(e);\n      if (ptr.inside) schedule();\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = false;\n      ptr.inside = false;\n      schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n      schedule();\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const listen = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n        window.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n        window.addEventListener(\"pointerup\", onPointerUp, { passive: true });\n        window.addEventListener(\"pointercancel\", onPointerUp, { passive: true });\n        document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        window.removeEventListener(\"pointerup\", onPointerUp);\n        window.removeEventListener(\"pointercancel\", onPointerUp);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n      }\n    };\n\n    // Pointer tracking lives on the window (only while visible), so content layered above the\n    // mosaic never blocks it and the mosaic never blocks the content.\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (interactive) listen(true);\n        schedule();\n      } else {\n        listen(false);\n        ptr.inside = false;\n        ptr.strength = 0;\n        stop();\n      }\n    });\n\n    readTheme();\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      listen(false);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [count, speed, lineWidth, fill, palette, pointer, showSeeds, accent, 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/voronoi-field"
}
