{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gravity-grid",
  "title": "Gravity grid",
  "description": "A grid of dots or lines that bends towards the pointer like a gravity well, on a canvas.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/gravity-grid/gravity-grid.tsx",
      "content": "import { useEffect, useRef, type ReactNode } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type GravityGridProps = {\n  /** Dots at each crossing, or the grid lines themselves. */\n  variant?: 'dots' | 'lines'\n  /** Distance between grid points in CSS pixels. */\n  gap?: number\n  /** How far from the pointer the pull reaches, in CSS pixels. */\n  reach?: number\n  /** How hard points are pulled; 1 is a gentle well. */\n  strength?: number\n  /** Classes for the grid layer; its text colour colours the grid, e.g. `text-primary/50`. */\n  gridClassName?: string\n  /** Content shown on top of the grid. */\n  children?: ReactNode\n  className?: string\n}\n\n// How quickly points catch up with where the pointer wants them, per 60 Hz frame.\nconst FOLLOW = 0.16\n\n/**\n * A grid of dots or lines that bends towards the pointer like a gravity well and settles when it leaves.\n * The grid takes its colour from `gridClassName` (muted by default) and lights up around the pointer. Static under reduced motion.\n */\nexport function GravityGrid({ variant = 'dots', gap = 28, reach = 180, strength = 1, gridClassName, children, className }: GravityGridProps) {\n  const canvas = useRef<HTMLCanvasElement>(null)\n  const reduce = useReducedMotion()\n\n  useEffect(() => {\n    const el = canvas.current\n    const ctx = el?.getContext('2d')\n    if (!el || !ctx) return\n    const step = Math.max(8, gap)\n\n    let w = 0\n    let h = 0\n    let cols = 0\n    let rows = 0\n    let x0 = 0\n    let y0 = 0\n    // Offset of each point from its rest position, and how strongly the pointer holds it (0 to 1).\n    let off = new Float32Array(0)\n    let pull = new Float32Array(0)\n    let pointer: { x: number; y: number } | null = null\n    // Where the light around the pointer sits, and how bright it is (0 to 1); both ease like the points.\n    let gx = 0\n    let gy = 0\n    let glow = 0\n    // Lines are lit by drawing them again on a second canvas and masking that to a soft circle.\n    const lit = document.createElement('canvas')\n    const lctx = lit.getContext('2d')\n\n    const draw = () => {\n      ctx.setTransform(1, 0, 0, 1, 0, 0)\n      ctx.clearRect(0, 0, el.width, el.height)\n      const dpr = el.width / Math.max(1, w)\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      const color = getComputedStyle(el).color\n      ctx.fillStyle = color\n      ctx.strokeStyle = color\n\n      if (variant === 'lines') {\n        const trace = (g: CanvasRenderingContext2D) => {\n          g.beginPath()\n          for (let r = 0; r < rows; r++) {\n            for (let c = 0; c < cols; c++) {\n              const i = r * cols + c\n              const x = x0 + c * step + off[i * 2]\n              const y = y0 + r * step + off[i * 2 + 1]\n              if (c === 0) g.moveTo(x, y)\n              else g.lineTo(x, y)\n            }\n          }\n          for (let c = 0; c < cols; c++) {\n            for (let r = 0; r < rows; r++) {\n              const i = r * cols + c\n              const x = x0 + c * step + off[i * 2]\n              const y = y0 + r * step + off[i * 2 + 1]\n              if (r === 0) g.moveTo(x, y)\n              else g.lineTo(x, y)\n            }\n          }\n          g.lineWidth = 1\n          g.stroke()\n        }\n        ctx.globalAlpha = 0.2\n        trace(ctx)\n        if (glow < 0.01 || !lctx) return\n        lctx.setTransform(1, 0, 0, 1, 0, 0)\n        lctx.globalCompositeOperation = 'source-over'\n        lctx.clearRect(0, 0, lit.width, lit.height)\n        lctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        lctx.strokeStyle = color\n        trace(lctx)\n        const mask = lctx.createRadialGradient(gx, gy, 0, gx, gy, reach)\n        mask.addColorStop(0, '#000')\n        mask.addColorStop(1, 'transparent')\n        lctx.globalCompositeOperation = 'destination-in'\n        lctx.fillStyle = mask\n        lctx.fillRect(0, 0, w, h)\n        ctx.setTransform(1, 0, 0, 1, 0, 0)\n        ctx.globalAlpha = glow\n        ctx.drawImage(lit, 0, 0)\n        return\n      }\n\n      for (let i = 0; i < cols * rows; i++) {\n        const p = pull[i]\n        ctx.globalAlpha = 0.35 + 0.65 * p\n        ctx.beginPath()\n        ctx.arc(x0 + (i % cols) * step + off[i * 2], y0 + Math.floor(i / cols) * step + off[i * 2 + 1], 1.2 * (1 + 1.1 * p), 0, Math.PI * 2)\n        ctx.fill()\n      }\n    }\n\n    const layout = () => {\n      w = el.clientWidth\n      h = el.clientHeight\n      const dpr = Math.min(2, window.devicePixelRatio || 1)\n      el.width = Math.max(1, Math.round(w * dpr))\n      el.height = Math.max(1, Math.round(h * dpr))\n      lit.width = el.width\n      lit.height = el.height\n      // One extra point beyond each edge, so bent lines never end inside the box.\n      cols = Math.floor(w / step) + 3\n      rows = Math.floor(h / step) + 3\n      x0 = (w - (cols - 1) * step) / 2\n      y0 = (h - (rows - 1) * step) / 2\n      off = new Float32Array(cols * rows * 2)\n      pull = new Float32Array(cols * rows)\n      draw()\n    }\n    const ro = new ResizeObserver(layout)\n    ro.observe(el)\n    layout()\n\n    // Redraw when the theme changes the text colour.\n    const mo = new MutationObserver(draw)\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })\n    const scheme = window.matchMedia('(prefers-color-scheme: dark)')\n    scheme.addEventListener('change', draw)\n\n    const cleanupStatic = () => {\n      ro.disconnect()\n      mo.disconnect()\n      scheme.removeEventListener('change', draw)\n    }\n    if (reduce) return cleanupStatic\n\n    let raf = 0\n    let last = 0\n    const tick = (now: number) => {\n      const k = 1 - Math.pow(1 - FOLLOW, Math.min(4, (now - (last || now - 16.7)) / 16.7))\n      last = now\n      let moving = false\n      if (pointer) {\n        // Jump straight to the pointer when the light is out, so it does not slide in from the last spot.\n        gx = glow < 0.01 ? pointer.x : gx + (pointer.x - gx) * k\n        gy = glow < 0.01 ? pointer.y : gy + (pointer.y - gy) * k\n      }\n      const eg = (pointer ? 1 : 0) - glow\n      glow += eg * k\n      if (Math.abs(eg) > 0.005) moving = true\n      for (let i = 0; i < cols * rows; i++) {\n        let tx = 0\n        let ty = 0\n        let tp = 0\n        if (pointer) {\n          const dx = pointer.x - (x0 + (i % cols) * step)\n          const dy = pointer.y - (y0 + Math.floor(i / cols) * step)\n          const d = Math.hypot(dx, dy)\n          if (d < reach) {\n            const f = (1 - d / reach) ** 2\n            tx = dx * f * 0.55 * strength\n            ty = dy * f * 0.55 * strength\n            tp = f\n          }\n        }\n        const ex = tx - off[i * 2]\n        const ey = ty - off[i * 2 + 1]\n        const ep = tp - pull[i]\n        off[i * 2] += ex * k\n        off[i * 2 + 1] += ey * k\n        pull[i] += ep * k\n        if (Math.abs(ex) > 0.05 || Math.abs(ey) > 0.05 || Math.abs(ep) > 0.005) moving = true\n      }\n      draw()\n      // Stop once everything has settled; the next pointer move starts it again.\n      if (moving) raf = requestAnimationFrame(tick)\n      else raf = last = 0\n    }\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(tick)\n    }\n\n    const move = (e: PointerEvent) => {\n      const box = el.getBoundingClientRect()\n      const x = e.clientX - box.left\n      const y = e.clientY - box.top\n      const near = x > -reach && y > -reach && x < box.width + reach && y < box.height + reach\n      if (!near && !pointer) return\n      pointer = near ? { x, y } : null\n      wake()\n    }\n    const leave = () => {\n      pointer = null\n      wake()\n    }\n    window.addEventListener('pointermove', move, { passive: true })\n    document.documentElement.addEventListener('pointerleave', leave)\n    window.addEventListener('blur', leave)\n\n    return () => {\n      cleanupStatic()\n      cancelAnimationFrame(raf)\n      window.removeEventListener('pointermove', move)\n      document.documentElement.removeEventListener('pointerleave', leave)\n      window.removeEventListener('blur', leave)\n    }\n  }, [variant, gap, reach, strength, reduce])\n\n  return (\n    <div className={cn('relative isolate overflow-hidden', className)}>\n      <canvas ref={canvas} aria-hidden className={cn('pointer-events-none absolute inset-0 -z-10 size-full text-muted-foreground', gridClassName)} />\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "surfaces"
  ],
  "type": "registry:component"
}