{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dot-matrix-field",
  "type": "registry:ui",
  "title": "Dot Matrix Field",
  "description": "A halftone dot grid that swells and lights up around the pointer, drawn on one canvas.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/dot-matrix-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type DotMatrixColorMode = \"mono\" | \"accent\" | \"gradient\";\n\nexport interface DotMatrixFieldProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Distance between dot centres, in px. Lower = denser. */\n  spacing?: number;\n  /** Resting dot radius, in px. */\n  dotSize?: number;\n  /** Radius of the pointer's influence, in px. */\n  influence?: number;\n  /** mono: text colour only · accent: lit dots shift to the accent · gradient: accent → secondary across the field. */\n  colorMode?: DotMatrixColorMode;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Second colour for gradient mode (any CSS colour). */\n  secondary?: string;\n  /** Subtle idle wave travelling across the grid. */\n  animate?: boolean;\n}\n\ntype RGB = [number, number, number];\nconst RAMP_STEPS = 24;\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/**\n * Resolves any CSS colour (hex, rgb(), oklch(), named…) to RGB by painting it on a 1×1 canvas.\n * Tailwind v4 palettes are oklch, so string parsing alone is not enough.\n */\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, leaving 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\n/** Precomputed colour strings between two colours, so the draw loop never builds strings. */\nfunction ramp(from: RGB, to: RGB): string[] {\n  return Array.from({ length: RAMP_STEPS }, (_, i) => {\n    const t = i / (RAMP_STEPS - 1);\n    const c = from.map((v, k) => Math.round(v + (to[k] - v) * t));\n    return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;\n  });\n}\n\nconst smoothstep = (t: number) => t * t * (3 - 2 * t);\n\n/**\n * An interactive halftone dot grid drawn on a single canvas. Dots near a mouse pointer grow\n * and brighten; touch devices get a static (or gently waving) field. The render loop only runs\n * while something is changing, and never while offscreen or in a hidden tab.\n */\nexport function DotMatrixField({\n  spacing = 18,\n  dotSize = 1.4,\n  influence = 140,\n  colorMode = \"accent\",\n  accent = \"#FF7448\",\n  secondary = \"#B6F3D1\",\n  animate = true,\n  className = \"\",\n  children,\n  ...props\n}: DotMatrixFieldProps) {\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 reducedMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const darkMq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    const accentRgb = resolveColor(accent, [255, 116, 72]);\n    const secondaryRgb = resolveColor(secondary, [182, 243, 209]);\n    const gap = Math.max(4, spacing);\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let fgRamp: string[] = [];\n    let columnColors: string[] = [];\n    let accentRamp: string[] = [];\n\n    // Pointer state, in canvas-local CSS px. `strength` eases in/out as the pointer enters/leaves.\n    const pointer = { clientX: 0, clientY: 0, x: 0, y: 0, tx: 0, ty: 0, strength: 0, target: 0, fresh: true };\n\n    /** Theme-aware base colour: the canvas inherits `color` from Tailwind text classes. */\n    const readTheme = () => {\n      const fg = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      fgRamp = ramp(fg, fg);\n      accentRamp = ramp(fg, accentRgb);\n      const cols = Math.floor(width / gap) + 1;\n      const gradient = ramp(accentRgb, secondaryRgb);\n      columnColors = Array.from({ length: cols }, (_, c) => gradient[Math.round((c / Math.max(1, cols - 1)) * (RAMP_STEPS - 1))]);\n    };\n\n    const waving = () => animate && !reducedMq.matches;\n\n    const draw = (time: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n\n      const cols = Math.floor(width / gap) + 1;\n      const rows = Math.floor(height / gap) + 1;\n      const ox = (width - (cols - 1) * gap) / 2;\n      const oy = (height - (rows - 1) * gap) / 2;\n      const wave = waving();\n      const s = pointer.strength;\n      const reach = Math.max(1, influence);\n\n      for (let r = 0; r < rows; r++) {\n        const y = oy + r * gap;\n        for (let c = 0; c < cols; c++) {\n          const x = ox + c * gap;\n\n          // Idle wave: a slow diagonal swell of size and opacity.\n          const w = wave ? (Math.sin(x * 0.016 + y * 0.011 - time * 0.0011) + 1) / 2 : 0.5;\n          let alpha = 0.16 + w * 0.16;\n          let radius = dotSize * (0.85 + w * 0.3);\n\n          let lit = 0;\n          if (s > 0.001) {\n            const dx = x - pointer.x;\n            const dy = y - pointer.y;\n            const d2 = dx * dx + dy * dy;\n            if (d2 < reach * reach) lit = smoothstep(1 - Math.sqrt(d2) / reach) * s;\n          }\n          alpha += (1 - alpha) * lit;\n          radius += dotSize * 1.9 * lit;\n\n          let fill: string;\n          if (colorMode === \"gradient\") fill = columnColors[c] ?? accentRamp[RAMP_STEPS - 1];\n          else if (colorMode === \"accent\") fill = accentRamp[Math.round(lit * (RAMP_STEPS - 1))];\n          else fill = fgRamp[0];\n\n          ctx.globalAlpha = colorMode === \"gradient\" ? Math.min(1, alpha * 1.6) : alpha;\n          ctx.fillStyle = fill;\n          ctx.beginPath();\n          ctx.arc(x, y, radius, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const frame = (time: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? time - last : 16.7);\n      last = time;\n\n      // Resolve pointer position against the current rect (handles scroll without listeners).\n      if (pointer.target > 0) {\n        const rect = canvas.getBoundingClientRect();\n        pointer.tx = pointer.clientX - rect.left;\n        pointer.ty = pointer.clientY - rect.top;\n        if (pointer.fresh) {\n          pointer.x = pointer.tx;\n          pointer.y = pointer.ty;\n          pointer.fresh = false;\n        }\n      }\n      // Frame-rate independent smoothing.\n      const k = 1 - Math.pow(1 - 0.22, dt / 16.7);\n      pointer.x += (pointer.tx - pointer.x) * k;\n      pointer.y += (pointer.ty - pointer.y) * k;\n      pointer.strength += (pointer.target - pointer.strength) * (1 - Math.pow(1 - 0.12, dt / 16.7));\n\n      draw(time);\n\n      const settling =\n        Math.abs(pointer.target - pointer.strength) > 0.002 ||\n        (pointer.strength > 0.002 && Math.hypot(pointer.tx - pointer.x, pointer.ty - pointer.y) > 0.3);\n      if (waving() || settling) schedule();\n      else last = 0;\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      readTheme();\n      draw(performance.now());\n      schedule();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\") return;\n      const rect = canvas.getBoundingClientRect();\n      const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;\n      const previous = pointer.target;\n      pointer.clientX = e.clientX;\n      pointer.clientY = e.clientY;\n      if (inside && previous === 0) pointer.fresh = pointer.strength < 0.01;\n      pointer.target = inside ? 1 : 0;\n      // Moves elsewhere on the page never wake the loop.\n      if (inside || previous !== pointer.target) schedule();\n    };\n    const onPointerLeave = () => {\n      pointer.target = 0;\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      draw(performance.now());\n      schedule();\n    };\n\n    const onVisibility = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        last = 0;\n      } else schedule();\n    };\n\n    // Pointer tracking is attached to the window only while the field is visible, so the\n    // background works whether content is layered inside it or beside it.\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n        document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        cancelAnimationFrame(raf);\n        raf = 0;\n        last = 0;\n      }\n    });\n\n    const ro = new ResizeObserver(resize);\n    // Class-based themes (e.g. `.dark` on <html>) change the inherited colour without a resize.\n    const mo = new MutationObserver(onThemeChange);\n\n    resize();\n    ro.observe(canvas);\n    io.observe(canvas);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n    darkMq.addEventListener(\"change\", onThemeChange);\n    reducedMq.addEventListener(\"change\", onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      darkMq.removeEventListener(\"change\", onThemeChange);\n      reducedMq.removeEventListener(\"change\", onThemeChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [spacing, dotSize, influence, colorMode, accent, secondary, animate]);\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/dot-matrix-field"
}
