{
  "name": "dot-field",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "// biome-ignore-all lint/suspicious/noBitwiseOperators: the noise hash is bit arithmetic.\n\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Dot Field\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { type ReactNode, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DotFieldProps {\n  className?: string;\n  /** Content shown over the field. Pass `null` for a bare background. */\n  children?: ReactNode;\n  /** Distance between dots, in px. */\n  spacing?: number;\n  /** Let a swipe push the dots and a click send a shockwave. */\n  interactive?: boolean;\n  /** Seed for the halftone pattern, so a page always opens on the same one. */\n  seed?: number;\n}\n\n// Width of a light or dark patch in the idle halftone, in px.\nconst NOISE_SCALE = 240;\n// How fast the halftone pattern drifts through time.\nconst NOISE_SPEED = 0.07;\n// The noise is sampled on every third dot and blended in between.\nconst TONE_STEP = 3;\n// The idle drift is slow; a frame every 33ms is plenty for it.\nconst IDLE_FRAME_MS = 33;\n// The membrane. Each dot is pulled back to its rest point (ANCHOR), towards\n// its four neighbours (COUPLING, which makes a disturbance travel as a wave)\n// and loses speed to DAMPING. Units are per second squared.\nconst ANCHOR = 26;\nconst COUPLING = 340;\nconst DAMPING = 2.4;\n// Physics runs in fixed steps so the springs behave the same at any refresh\n// rate.\nconst STEP = 1 / 120;\n// The pointer: radius it reaches (px), how much of its velocity it hands to\n// the dots it passes (per second), and the fastest swipe it counts (px/s).\nconst POINTER_RADIUS = 72;\nconst POINTER_GAIN = 14;\nconst POINTER_MAX_SPEED = 2600;\n// Share of the swipe speed that also pushes dots sideways, out of its path,\n// so the wake opens into a V like water behind a boat.\nconst POINTER_PART = 0.45;\n// A click: how fast the ring travels (px/s), how thick it is (px), how hard\n// it shoves (px/s²) and how long it lives (s).\nconst SHOCK_SPEED = 620;\nconst SHOCK_WIDTH = 34;\nconst SHOCK_FORCE = 5200;\nconst SHOCK_LIFE = 1.3;\n// Squeezed dots grow and stretched ones shrink, so the wake reads as a band\n// of darker and lighter tone, like ink in a halftone print.\nconst COMPRESSION_GAIN = 3;\n// Dot sizes are snapped to this many steps; each step is one path.\nconst BUCKETS = 10;\n// Below these the field counts as settled and the physics sleeps.\nconst REST_SPEED = 2;\nconst REST_OFFSET = 0.15;\n\ninterface Shock {\n  x: number;\n  y: number;\n  age: number;\n}\n\ninterface Pointer {\n  x: number;\n  y: number;\n  lastX: number;\n  lastY: number;\n  inside: boolean;\n}\n\n/** Seeded 3D gradient noise (Ken Perlin's improved noise), about -1 to 1. */\nfunction createNoise(seed: number) {\n  const p = Array.from({ length: 256 }, (_, i) => i);\n  let state = seed >>> 0 || 1;\n  const random = () => {\n    state = (state * 1_664_525 + 1_013_904_223) >>> 0;\n    return state / 4_294_967_296;\n  };\n  for (let i = 255; i > 0; i--) {\n    const j = Math.floor(random() * (i + 1));\n    [p[i], p[j]] = [p[j], p[i]];\n  }\n  const perm = new Uint8Array(512);\n  for (let i = 0; i < 512; i++) {\n    perm[i] = p[i & 255];\n  }\n  const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);\n  const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n  const grad = (hash: number, x: number, y: number, z: number) => {\n    const h = hash & 15;\n    const u = h < 8 ? x : y;\n    let v = z;\n    if (h < 4) {\n      v = y;\n    } else if (h === 12 || h === 14) {\n      v = x;\n    }\n    return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);\n  };\n\n  return (x: number, y: number, z: number) => {\n    const X = Math.floor(x) & 255;\n    const Y = Math.floor(y) & 255;\n    const Z = Math.floor(z) & 255;\n    const fx = x - Math.floor(x);\n    const fy = y - Math.floor(y);\n    const fz = z - Math.floor(z);\n    const u = fade(fx);\n    const v = fade(fy);\n    const w = fade(fz);\n    const A = perm[X] + Y;\n    const AA = perm[A] + Z;\n    const AB = perm[A + 1] + Z;\n    const B = perm[X + 1] + Y;\n    const BA = perm[B] + Z;\n    const BB = perm[B + 1] + Z;\n    return lerp(\n      lerp(\n        lerp(grad(perm[AA], fx, fy, fz), grad(perm[BA], fx - 1, fy, fz), u),\n        lerp(\n          grad(perm[AB], fx, fy - 1, fz),\n          grad(perm[BB], fx - 1, fy - 1, fz),\n          u\n        ),\n        v\n      ),\n      lerp(\n        lerp(\n          grad(perm[AA + 1], fx, fy, fz - 1),\n          grad(perm[BA + 1], fx - 1, fy, fz - 1),\n          u\n        ),\n        lerp(\n          grad(perm[AB + 1], fx, fy - 1, fz - 1),\n          grad(perm[BB + 1], fx - 1, fy - 1, fz - 1),\n          u\n        ),\n        v\n      ),\n      w\n    );\n  };\n}\n\n/** The grid of dots and the state of every spring, in flat arrays. */\ninterface Field {\n  cols: number;\n  rows: number;\n  spacing: number;\n  originX: number;\n  originY: number;\n  /** Idle size of each dot from the noise, 0 to 1. */\n  tone: Float32Array;\n  /** The noise on a coarser grid, blended into `tone`. */\n  coarse: Float32Array;\n  ox: Float32Array;\n  oy: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  ax: Float32Array;\n  ay: Float32Array;\n}\n\nfunction createField(width: number, height: number, spacing: number): Field {\n  // One extra dot past each edge, so the membrane has no visible border.\n  const cols = Math.ceil(width / spacing) + 3;\n  const rows = Math.ceil(height / spacing) + 3;\n  const size = cols * rows;\n  return {\n    cols,\n    rows,\n    spacing,\n    originX: (width - (cols - 1) * spacing) / 2,\n    originY: (height - (rows - 1) * spacing) / 2,\n    tone: new Float32Array(size),\n    coarse: new Float32Array(\n      (Math.ceil((cols - 1) / TONE_STEP) + 1) *\n        (Math.ceil((rows - 1) / TONE_STEP) + 1)\n    ),\n    ox: new Float32Array(size),\n    oy: new Float32Array(size),\n    vx: new Float32Array(size),\n    vy: new Float32Array(size),\n    ax: new Float32Array(size),\n    ay: new Float32Array(size),\n  };\n}\n\n/**\n * The idle halftone. The noise is smooth over a few hundred px, so it is\n * sampled on every TONE_STEP-th dot and blended in between, which costs a\n * ninth of sampling every dot.\n */\nfunction sampleTone(\n  field: Field,\n  noise: (x: number, y: number, z: number) => number,\n  time: number\n) {\n  const { cols, rows, spacing, originX, originY, tone, coarse } = field;\n  const z = time * NOISE_SPEED;\n  const step = spacing * TONE_STEP;\n  const coarseCols = Math.ceil((cols - 1) / TONE_STEP) + 1;\n  const coarseRows = Math.ceil((rows - 1) / TONE_STEP) + 1;\n  for (let j = 0; j < coarseRows; j++) {\n    const ny = (originY + j * step) / NOISE_SCALE;\n    for (let i = 0; i < coarseCols; i++) {\n      const nx = (originX + i * step) / NOISE_SCALE;\n      const n =\n        noise(nx, ny, z) * 0.75 +\n        noise(nx * 2.1 + 9.3, ny * 2.1 + 3.7, z * 1.3) * 0.25;\n      coarse[j * coarseCols + i] = Math.min(1, Math.max(0, 0.5 + n * 1.1));\n    }\n  }\n  for (let j = 0; j < rows; j++) {\n    const cj = Math.min(Math.floor(j / TONE_STEP), coarseRows - 2);\n    const fy = j / TONE_STEP - cj;\n    for (let i = 0; i < cols; i++) {\n      const ci = Math.min(Math.floor(i / TONE_STEP), coarseCols - 2);\n      const fx = i / TONE_STEP - ci;\n      const a = cj * coarseCols + ci;\n      const top = coarse[a] + (coarse[a + 1] - coarse[a]) * fx;\n      const bottom =\n        coarse[a + coarseCols] +\n        (coarse[a + coarseCols + 1] - coarse[a + coarseCols]) * fx;\n      tone[j * cols + i] = top + (bottom - top) * fy;\n    }\n  }\n}\n\n/** Sum of the four neighbours; past the border counts as a still dot. */\nfunction neighbours(\n  values: Float32Array,\n  field: Field,\n  i: number,\n  j: number\n): number {\n  const { cols, rows } = field;\n  const k = j * cols + i;\n  let sum = 0;\n  if (i > 0) {\n    sum += values[k - 1];\n  }\n  if (i < cols - 1) {\n    sum += values[k + 1];\n  }\n  if (j > 0) {\n    sum += values[k - cols];\n  }\n  if (j < rows - 1) {\n    sum += values[k + cols];\n  }\n  return sum;\n}\n\n/** Spring force on every dot: back to rest, towards neighbours, damping. */\nfunction accelerate(field: Field) {\n  const { cols, rows, ox, oy, vx, vy, ax, ay } = field;\n  for (let j = 0; j < rows; j++) {\n    for (let i = 0; i < cols; i++) {\n      const k = j * cols + i;\n      const sx = neighbours(ox, field, i, j);\n      const sy = neighbours(oy, field, i, j);\n      ax[k] += -ANCHOR * ox[k] + COUPLING * (sx - 4 * ox[k]) - DAMPING * vx[k];\n      ay[k] += -ANCHOR * oy[k] + COUPLING * (sy - 4 * oy[k]) - DAMPING * vy[k];\n    }\n  }\n}\n\n/** A ring travelling out from each click, shoving the dots it crosses. */\nfunction applyShocks(field: Field, shocks: Shock[]) {\n  const { cols, rows, spacing, originX, originY, ax, ay } = field;\n  for (const shock of shocks) {\n    const reach = shock.age * SHOCK_SPEED;\n    const fade = 1 - shock.age / SHOCK_LIFE;\n    const force = SHOCK_FORCE * fade * fade;\n    const outer = reach + SHOCK_WIDTH;\n    const inner = Math.max(0, reach - SHOCK_WIDTH);\n    // Only the rows and columns the ring's box touches.\n    const i0 = Math.max(0, Math.floor((shock.x - outer - originX) / spacing));\n    const i1 = Math.min(\n      cols - 1,\n      Math.ceil((shock.x + outer - originX) / spacing)\n    );\n    const j0 = Math.max(0, Math.floor((shock.y - outer - originY) / spacing));\n    const j1 = Math.min(\n      rows - 1,\n      Math.ceil((shock.y + outer - originY) / spacing)\n    );\n    for (let j = j0; j <= j1; j++) {\n      const dy = originY + j * spacing - shock.y;\n      for (let i = i0; i <= i1; i++) {\n        const dx = originX + i * spacing - shock.x;\n        const dist = Math.hypot(dx, dy);\n        if (dist < inner || dist > outer || dist === 0) {\n          continue;\n        }\n        const band = 1 - Math.abs(dist - reach) / SHOCK_WIDTH;\n        const k = j * cols + i;\n        ax[k] += (dx / dist) * force * band;\n        ay[k] += (dy / dist) * force * band;\n      }\n    }\n  }\n}\n\n/**\n * The pointer hands part of its velocity to the dots near the path it took\n * since the last frame. A slow drift barely stirs them; a fast swipe drags\n * a strip of the membrane along, and the springs turn that into a wake.\n */\nfunction applyPointer(field: Field, pointer: Pointer, dt: number) {\n  const segX = pointer.x - pointer.lastX;\n  const segY = pointer.y - pointer.lastY;\n  pointer.lastX = pointer.x;\n  pointer.lastY = pointer.y;\n  const length = Math.hypot(segX, segY);\n  if (!pointer.inside || length < 0.5 || dt <= 0) {\n    return false;\n  }\n  const speed = Math.min(length / dt, POINTER_MAX_SPEED);\n  const pvx = (segX / length) * speed;\n  const pvy = (segY / length) * speed;\n  const { cols, rows, spacing, originX, originY, vx, vy } = field;\n  const startX = pointer.x - segX;\n  const startY = pointer.y - segY;\n  const minX = Math.min(startX, pointer.x) - POINTER_RADIUS;\n  const maxX = Math.max(startX, pointer.x) + POINTER_RADIUS;\n  const minY = Math.min(startY, pointer.y) - POINTER_RADIUS;\n  const maxY = Math.max(startY, pointer.y) + POINTER_RADIUS;\n  const i0 = Math.max(0, Math.floor((minX - originX) / spacing));\n  const i1 = Math.min(cols - 1, Math.ceil((maxX - originX) / spacing));\n  const j0 = Math.max(0, Math.floor((minY - originY) / spacing));\n  const j1 = Math.min(rows - 1, Math.ceil((maxY - originY) / spacing));\n  const lengthSq = length * length;\n  const gain = Math.min(1, POINTER_GAIN * dt);\n  for (let j = j0; j <= j1; j++) {\n    const py = originY + j * spacing;\n    for (let i = i0; i <= i1; i++) {\n      const px = originX + i * spacing;\n      // Distance from the dot to the swipe segment.\n      const t = Math.min(\n        1,\n        Math.max(0, ((px - startX) * segX + (py - startY) * segY) / lengthSq)\n      );\n      const awayX = px - startX - segX * t;\n      const awayY = py - startY - segY * t;\n      const dist = Math.hypot(awayX, awayY);\n      if (dist >= POINTER_RADIUS) {\n        continue;\n      }\n      const falloff = 1 - dist / POINTER_RADIUS;\n      const k = j * cols + i;\n      const amount = gain * falloff * falloff;\n      const side = dist > 0 ? (speed * POINTER_PART) / dist : 0;\n      vx[k] += (pvx + awayX * side - vx[k]) * amount;\n      vy[k] += (pvy + awayY * side - vy[k]) * amount;\n    }\n  }\n  return true;\n}\n\n/** One fixed physics step. Returns whether anything is still moving. */\nfunction integrate(field: Field, shocks: Shock[], h: number) {\n  const { ox, oy, vx, vy, ax, ay } = field;\n  ax.fill(0);\n  ay.fill(0);\n  applyShocks(field, shocks);\n  accelerate(field);\n  let moving = false;\n  for (let k = 0; k < ox.length; k++) {\n    vx[k] += ax[k] * h;\n    vy[k] += ay[k] * h;\n    ox[k] += vx[k] * h;\n    oy[k] += vy[k] * h;\n    if (\n      !moving &&\n      (Math.abs(vx[k]) > REST_SPEED ||\n        Math.abs(vy[k]) > REST_SPEED ||\n        Math.abs(ox[k]) > REST_OFFSET ||\n        Math.abs(oy[k]) > REST_OFFSET)\n    ) {\n      moving = true;\n    }\n  }\n  return moving;\n}\n\n/** How much the membrane is squeezed (+) or stretched (-) at one dot. */\nfunction compression(field: Field, i: number, j: number) {\n  const { cols, rows, spacing, ox, oy } = field;\n  const k = j * cols + i;\n  const left = i > 0 ? ox[k - 1] : 0;\n  const right = i < cols - 1 ? ox[k + 1] : 0;\n  const up = j > 0 ? oy[k - cols] : 0;\n  const down = j < rows - 1 ? oy[k + cols] : 0;\n  return -(right - left + down - up) / (2 * spacing);\n}\n\n/**\n * Draw every dot. Sizes are snapped to a few steps and each step is one\n * path, so the whole field is a handful of fills instead of thousands.\n */\nfunction draw(\n  ctx: CanvasRenderingContext2D,\n  field: Field,\n  size: { width: number; height: number; color: string },\n  moving: boolean\n) {\n  const { cols, rows, spacing, originX, originY, tone, ox, oy } = field;\n  const maxRadius = spacing * 0.42;\n  const paths = Array.from({ length: BUCKETS }, () => new Path2D());\n  const filled = new Uint8Array(BUCKETS);\n  for (let j = 0; j < rows; j++) {\n    for (let i = 0; i < cols; i++) {\n      const k = j * cols + i;\n      let amount = 0.12 + 0.6 * tone[k];\n      if (moving) {\n        amount *= 1 + COMPRESSION_GAIN * compression(field, i, j);\n      }\n      const bucket = Math.round(\n        Math.min(1, Math.max(0, amount)) * (BUCKETS - 1)\n      );\n      if (bucket === 0) {\n        continue;\n      }\n      const radius = (maxRadius * bucket) / (BUCKETS - 1);\n      const x = originX + i * spacing + ox[k];\n      const y = originY + j * spacing + oy[k];\n      const path = paths[bucket];\n      path.moveTo(x + radius, y);\n      path.arc(x, y, radius, 0, Math.PI * 2);\n      filled[bucket] = 1;\n    }\n  }\n  ctx.clearRect(0, 0, size.width, size.height);\n  ctx.fillStyle = size.color;\n  for (let b = 1; b < BUCKETS; b++) {\n    if (filled[b]) {\n      // Larger dots are a touch more opaque, which deepens the tone range.\n      ctx.globalAlpha = 0.2 + (0.32 * b) / (BUCKETS - 1);\n      ctx.fill(paths[b]);\n    }\n  }\n  ctx.globalAlpha = 1;\n}\n\nexport default function DotField({\n  className,\n  children,\n  spacing = 16,\n  interactive = true,\n  seed = 7,\n}: DotFieldProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!(root && canvas && ctx)) {\n      return;\n    }\n\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const noise = createNoise(seed);\n    const gap = Math.max(6, spacing);\n    const size = { width: 0, height: 0, color: \"\" };\n    let field = createField(0, 0, gap);\n\n    const pointer: Pointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      inside: false,\n    };\n    const shocks: Shock[] = [];\n    let time = 0;\n    let last = 0;\n    let lastTone = 0;\n    let carry = 0;\n    // What the last physics step found. Frames shorter than STEP (144Hz\n    // screens) run no step, so they go by this rather than settle early.\n    let stirring = false;\n    let awake = false;\n    let raf = 0;\n    let visible = true;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      size.width = rect.width;\n      size.height = rect.height;\n      canvas.width = Math.round(size.width * dpr);\n      canvas.height = Math.round(size.height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      size.color = getComputedStyle(canvas).color;\n      field = createField(size.width, size.height, gap);\n      sampleTone(field, noise, time);\n      awake = false;\n    };\n\n    const physics = (dt: number) => {\n      const pushed = applyPointer(field, pointer, dt);\n      carry = Math.min(carry + dt, STEP * 6);\n      while (carry >= STEP) {\n        carry -= STEP;\n        for (const shock of shocks) {\n          shock.age += STEP;\n        }\n        while (shocks.length > 0 && shocks[0].age > SHOCK_LIFE) {\n          shocks.shift();\n        }\n        stirring = integrate(field, shocks, STEP);\n      }\n      if (!(pushed || stirring || shocks.length > 0)) {\n        // Settled: put every dot exactly home and let the physics sleep.\n        field.ox.fill(0);\n        field.oy.fill(0);\n        field.vx.fill(0);\n        field.vy.fill(0);\n        awake = false;\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!visible) {\n        return;\n      }\n      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;\n      last = now;\n      time += dt;\n      const wasAwake = awake;\n      if (awake) {\n        physics(dt);\n      }\n      const toneDue = now - lastTone >= IDLE_FRAME_MS;\n      if (toneDue) {\n        sampleTone(field, noise, time);\n        lastTone = now;\n      }\n      // The drift alone gets about 30 frames a second; a wake gets every one.\n      if (wasAwake || toneDue) {\n        draw(ctx, field, size, wasAwake);\n      }\n      raf = requestAnimationFrame(frame);\n    };\n\n    const start = () => {\n      if (!raf && visible && !reduceMotion) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const wake = () => {\n      if (!awake) {\n        awake = true;\n        carry = 0;\n        stirring = true;\n      }\n      start();\n    };\n\n    resize();\n    draw(ctx, field, size, false);\n    canvas.style.opacity = \"1\";\n    start();\n\n    const redraw = () => {\n      resize();\n      draw(ctx, field, size, false);\n    };\n    const resizeObserver = new ResizeObserver(redraw);\n    resizeObserver.observe(canvas);\n\n    // Moving the window to a screen with another pixel ratio doesn't resize\n    // the canvas, so watch the ratio too or the dots go soft.\n    let dprQuery: MediaQueryList | null = null;\n    const watchDpr = () => {\n      dprQuery?.removeEventListener(\"change\", onDpr);\n      dprQuery = window.matchMedia(\n        `(resolution: ${window.devicePixelRatio}dppx)`\n      );\n      dprQuery.addEventListener(\"change\", onDpr);\n    };\n    const onDpr = () => {\n      redraw();\n      watchDpr();\n    };\n    watchDpr();\n\n    // Stop drawing entirely while the field is scrolled away or the tab is\n    // in the background.\n    let onScreen = true;\n    const updateVisible = () => {\n      visible = onScreen && document.visibilityState === \"visible\";\n      if (visible) {\n        start();\n      }\n    };\n    const intersection = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      updateVisible();\n    });\n    intersection.observe(root);\n    document.addEventListener(\"visibilitychange\", updateVisible);\n\n    // Follow light and dark mode without asking the app for a theme. Only\n    // checked while the field is on screen.\n    const colorTimer = window.setInterval(() => {\n      if (!visible) {\n        return;\n      }\n      const next = getComputedStyle(canvas).color;\n      if (next !== size.color) {\n        size.color = next;\n        if (reduceMotion) {\n          draw(ctx, field, size, false);\n        }\n      }\n    }, 500);\n\n    const local = (event: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      return { x: event.clientX - rect.left, y: event.clientY - rect.top };\n    };\n    const onMove = (event: PointerEvent) => {\n      const point = local(event);\n      if (!pointer.inside) {\n        // Start from where the pointer came in, not where it last left.\n        pointer.lastX = point.x;\n        pointer.lastY = point.y;\n        pointer.inside = true;\n      }\n      pointer.x = point.x;\n      pointer.y = point.y;\n      if (\n        Math.abs(pointer.x - pointer.lastX) > 0.5 ||\n        Math.abs(pointer.y - pointer.lastY) > 0.5\n      ) {\n        wake();\n      }\n    };\n    const onLeave = () => {\n      pointer.inside = false;\n    };\n    const onDown = (event: PointerEvent) => {\n      // Clicks on the content (links, buttons) are not meant for the field,\n      // and neither are right or middle clicks.\n      if (\n        event.button !== 0 ||\n        (event.target as HTMLElement).closest(\n          \"a, button, input, textarea, select, label, [role='button']\"\n        )\n      ) {\n        return;\n      }\n      const point = local(event);\n      pointer.x = point.x;\n      pointer.y = point.y;\n      pointer.lastX = point.x;\n      pointer.lastY = point.y;\n      pointer.inside = true;\n      shocks.push({ ...point, age: 0 });\n      if (shocks.length > 3) {\n        shocks.shift();\n      }\n      wake();\n    };\n    if (interactive && !reduceMotion) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerleave\", onLeave);\n      root.addEventListener(\"pointerdown\", onDown);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      resizeObserver.disconnect();\n      dprQuery?.removeEventListener(\"change\", onDpr);\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", updateVisible);\n      window.clearInterval(colorTimer);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [spacing, interactive, seed]);\n\n  return (\n    <div\n      className={cn(\n        \"relative flex min-h-[560px] w-full items-center justify-center overflow-hidden bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n      ref={rootRef}\n    >\n      {/* Faded out at the edges so the field sits into the page around it. */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        <canvas\n          className=\"h-full w-full opacity-0 transition-opacity duration-500 ease-out\"\n          ref={canvasRef}\n          style={{\n            // Fade at the edges, and quieter in the middle where the\n            // content usually sits so text stays easy to read.\n            maskImage:\n              \"radial-gradient(ellipse 75% 70% at 50% 50%, #000 40%, transparent 100%), radial-gradient(ellipse 34% 22% at 50% 50%, rgb(0 0 0 / 0.35), #000 100%)\",\n            maskComposite: \"intersect\",\n          }}\n        />\n      </div>\n      {children === undefined ? <DefaultContent /> : children}\n    </div>\n  );\n}\n\nfunction DefaultContent() {\n  return (\n    <div className=\"relative flex max-w-xl flex-col items-center gap-4 px-6 text-center\">\n      <h1 className=\"font-semibold text-4xl tracking-tight sm:text-5xl\">\n        Leave a mark\n      </h1>\n      <p className=\"max-w-sm text-base text-zinc-500 dark:text-zinc-400\">\n        A halftone membrane. Swipe fast to leave a wake, click to send a\n        shockwave through it.\n      </p>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/dot-field.tsx",
      "target": "components/kokonutui/dot-field.tsx"
    }
  ]
}