{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "warp-grid",
  "type": "registry:ui",
  "title": "Warp Grid",
  "description": "Grid lines that bend around the pointer like a gravitational lens and spring back when it leaves.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/warp-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type WarpGridMode = \"attract\" | \"repel\";\n\nexport interface WarpGridProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Distance between grid lines, in px. */\n  spacing?: number;\n  /** How far the grid bends toward (or away from) the pointer, 0–1. */\n  strength?: number;\n  /** Pointer influence radius, in px. */\n  radius?: number;\n  /** Pull the lines in like a lens, or push them away. */\n  mode?: WarpGridMode;\n  /** Send a soft ripple through the grid every few seconds. */\n  ripples?: boolean;\n  /** Colour of the lines near the pointer (any CSS colour). */\n  accent?: string;\n  /** Grid line width, in px. */\n  lineWidth?: number;\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 accents 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/* -------------------------------- Component -------------------------------- */\n\nconst STEP_MS = 1000 / 60;\nconst SPRING = 0.14; // pull toward the target displacement per step\nconst DAMPING = 0.78; // velocity kept per step: < 1 settles, with a little wobble on release\nconst RIPPLE_SPEED = 300; // px per second\nconst RIPPLE_LIFE = 2.4; // seconds\nconst RIPPLE_AMPLITUDE = 9; // px\nconst IDLE_RIPPLE_MS = 3600;\n\n/**\n * A line grid that bends around the pointer like a gravitational lens. Every intersection is a\n * damped spring chasing a target displacement (a smooth falloff toward or away from the pointer,\n * plus any passing ripples), so the grid eases in, wobbles slightly and springs back when the\n * pointer leaves. Rows and columns are stroked as polylines; a second pass with a radial\n * gradient tints the lines near the pointer. The loop stops once every node has settled.\n */\nexport function WarpGrid({\n  spacing = 36,\n  strength = 0.5,\n  radius = 180,\n  mode = \"attract\",\n  ripples = true,\n  accent = \"#FF7448\",\n  lineWidth = 1,\n  className = \"\",\n  children,\n  ...props\n}: WarpGridProps) {\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 gap = Math.max(8, spacing);\n    const reach = Math.max(20, radius);\n    const pull = Math.min(1, Math.max(0, strength));\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let x0 = 0;\n    let y0 = 0;\n    let raf = 0;\n    let sleep = 0;\n    let last = 0;\n    let pending = 0;\n    // Result of the most recent physics step. On >60Hz displays a frame can be shorter than\n    // one step, so \"no step ran this frame\" must not be mistaken for \"everything settled\".\n    let lastMoving = true;\n    let inView = false;\n    let idleClock = IDLE_RIPPLE_MS * 0.4;\n    let ink = \"rgba(24, 24, 27, 0.14)\";\n    let accentRGB: RGB = [255, 116, 72];\n\n    // Per-node displacement and velocity (flat typed arrays, index = row * cols + col).\n    let ox = new Float32Array(0);\n    let oy = new Float32Array(0);\n    let vx = new Float32Array(0);\n    let vy = new Float32Array(0);\n\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, strength: 0 };\n    let waves: { x: number; y: number; age: number }[] = [];\n\n    const readTheme = () => {\n      const base = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const dark = luminance(base) > 0.5;\n      ink = `rgba(${base[0]}, ${base[1]}, ${base[2]}, ${dark ? 0.16 : 0.15})`;\n      accentRGB = legible(resolveColor(accent, [255, 116, 72]), base);\n    };\n\n    const layout = () => {\n      // One extra line beyond each edge, so displaced edges never reveal a gap.\n      cols = Math.ceil(width / gap) + 3;\n      rows = Math.ceil(height / gap) + 3;\n      x0 = (width - (cols - 1) * gap) / 2;\n      y0 = (height - (rows - 1) * gap) / 2;\n      const n = cols * rows;\n      ox = new Float32Array(n);\n      oy = new Float32Array(n);\n      vx = new Float32Array(n);\n      vy = new Float32Array(n);\n    };\n\n    /** One fixed 60Hz step. Returns true while anything is still moving. */\n    const step = () => {\n      const s = ptr.strength;\n      const r2 = reach * reach;\n      let moving = false;\n      for (let j = 0; j < rows; j++) {\n        const by = y0 + j * gap;\n        for (let i = 0; i < cols; i++) {\n          const k = j * cols + i;\n          const bx = x0 + i * gap;\n          let tx = 0;\n          let ty = 0;\n\n          if (s > 0.001) {\n            const dx = bx - ptr.x;\n            const dy = by - ptr.y;\n            const d2 = dx * dx + dy * dy;\n            if (d2 < r2 && d2 > 0.01) {\n              const d = Math.sqrt(d2);\n              const u = 1 - d2 / r2;\n              const f = u * u * pull * s;\n              // Attract scales with distance (a lens pinch that never crosses the pointer);\n              // repel pushes by a share of the radius. Both stay monotonic, so lines never fold.\n              const m = mode === \"attract\" ? -f * d * 0.55 : f * reach * 0.28;\n              tx += (dx / d) * m;\n              ty += (dy / d) * m;\n            }\n          }\n\n          for (const w of waves) {\n            const dx = bx - w.x;\n            const dy = by - w.y;\n            const d = Math.sqrt(dx * dx + dy * dy) || 1;\n            const ring = d - w.age * RIPPLE_SPEED;\n            if (ring > -90 && ring < 90) {\n              const m = RIPPLE_AMPLITUDE * (1 - w.age / RIPPLE_LIFE) * Math.exp(-(ring * ring) / 1600) * Math.sin(ring * 0.05);\n              tx += (dx / d) * m;\n              ty += (dy / d) * m;\n            }\n          }\n\n          vx[k] = (vx[k] + (tx - ox[k]) * SPRING) * DAMPING;\n          vy[k] = (vy[k] + (ty - oy[k]) * SPRING) * DAMPING;\n          ox[k] += vx[k];\n          oy[k] += vy[k];\n          if (!moving && (Math.abs(vx[k]) > 0.01 || Math.abs(vy[k]) > 0.01 || Math.abs(tx - ox[k]) > 0.05 || Math.abs(ty - oy[k]) > 0.05)) {\n            moving = true;\n          }\n        }\n      }\n      return moving;\n    };\n\n    const buildPath = () => {\n      const path = new Path2D();\n      for (let j = 0; j < rows; j++) {\n        const by = y0 + j * gap;\n        for (let i = 0; i < cols; i++) {\n          const k = j * cols + i;\n          const x = x0 + i * gap + ox[k];\n          const y = by + oy[k];\n          if (i === 0) path.moveTo(x, y);\n          else path.lineTo(x, y);\n        }\n      }\n      for (let i = 0; i < cols; i++) {\n        const bx = x0 + i * gap;\n        for (let j = 0; j < rows; j++) {\n          const k = j * cols + i;\n          const x = bx + ox[k];\n          const y = y0 + j * gap + oy[k];\n          if (j === 0) path.moveTo(x, y);\n          else path.lineTo(x, y);\n        }\n      }\n      return path;\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      const path = buildPath();\n      ctx.lineWidth = lineWidth;\n      ctx.lineJoin = \"round\";\n      ctx.strokeStyle = ink;\n      ctx.stroke(path);\n      if (ptr.strength > 0.02) {\n        // Accent pass: the same lines, tinted by a radial gradient centred on the pointer.\n        const [r, g, b] = accentRGB;\n        const glow = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, reach);\n        glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${(0.95 * ptr.strength).toFixed(3)})`);\n        glow.addColorStop(0.55, `rgba(${r}, ${g}, ${b}, ${(0.35 * ptr.strength).toFixed(3)})`);\n        glow.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);\n        ctx.strokeStyle = glow;\n        ctx.lineWidth = lineWidth + 0.25;\n        ctx.stroke(path);\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n\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.86, dt / STEP_MS));\n      if (!ptr.inside && ptr.strength < 0.005) ptr.strength = 0;\n\n      if (ripples) {\n        idleClock += dt;\n        if (idleClock >= IDLE_RIPPLE_MS) {\n          idleClock = 0;\n          waves.push({ x: width * (0.15 + Math.random() * 0.7), y: height * (0.2 + Math.random() * 0.6), age: 0 });\n        }\n      }\n      for (const w of waves) w.age += dt / 1000;\n      waves = waves.filter((w) => w.age < RIPPLE_LIFE);\n\n      // Fixed 60Hz steps keep the spring identical on 60, 120 or 144Hz displays.\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      let stepped = false;\n      let moving = false;\n      while (pending >= STEP_MS) {\n        moving = step() || moving;\n        stepped = true;\n        pending -= STEP_MS;\n      }\n      if (stepped) lastMoving = moving;\n      draw();\n\n      if (lastMoving || ptr.inside || ptr.strength > 0 || waves.length > 0) schedule();\n      else if (ripples) {\n        // Settled: sleep (no rAF) until the next idle ripple is due.\n        last = 0;\n        sleep = window.setTimeout(() => {\n          sleep = 0;\n          idleClock = IDLE_RIPPLE_MS;\n          schedule();\n        }, IDLE_RIPPLE_MS - idleClock);\n      }\n    };\n\n    const schedule = () => {\n      if (sleep) {\n        clearTimeout(sleep);\n        sleep = 0;\n      }\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      clearTimeout(sleep);\n      sleep = 0;\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 && cols > 0) return;\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      layout();\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\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      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;\n      if (inside && !ptr.inside && ptr.strength < 0.01) {\n        ptr.x = e.clientX - rect.left;\n        ptr.y = e.clientY - rect.top;\n      }\n      ptr.inside = inside;\n      if (inside) schedule();\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n      if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;\n      waves.push({ x, y, age: 0 });\n      if (waves.length > 5) waves.shift();\n      schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    // Pointer tracking lives on the window (only while visible), so content layered above the\n    // grid never blocks it and the grid never blocks the content.\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (!reduceMotion) {\n          window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n          window.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        }\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        ptr.inside = false;\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      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [spacing, strength, radius, mode, ripples, accent, lineWidth, 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/warp-grid"
}
