{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dot-grid","type":"registry:component","title":"Dot grid","description":"Dots swell and part around your cursor, and a click sends a shockwave.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/dot-grid.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst SPACING = 16;\nconst DOT = 1.5;\n// Dots within this radius feel the cursor; falloff is quadratic to the edge.\nconst FIELD = 88;\n// Far enough to read as a push, small enough that neighbours never overlap.\nconst PUSH = 7;\nconst GROW = 1.3;\n// Slightly underdamped (ratio about 0.7), so dots spring back with a hint of\n// give instead of sliding home.\nconst STIFFNESS = 170;\nconst DAMPING = 18;\n// A click ring crosses the 480px grid in about 0.9s.\nconst RING_SPEED = 620;\nconst RING_WIDTH = 22;\nconst RING_PUSH = 9;\nconst REST = 0.01;\n\ntype Ring = { x: number; y: number; born: number };\n\nexport function DotGrid({ className }: { className?: string }) {\n  const reduce = useReducedMotion() ?? false;\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!wrap || !canvas || !ctx) return;\n\n    let width = 0;\n    let height = 0;\n    let count = 0;\n    // Flat typed arrays keep the per-frame loop allocation free.\n    let baseX = new Float32Array(0);\n    let baseY = new Float32Array(0);\n    let offX = new Float32Array(0);\n    let offY = new Float32Array(0);\n    let velX = new Float32Array(0);\n    let velY = new Float32Array(0);\n    let size = new Float32Array(0);\n    let velSize = new Float32Array(0);\n\n    let pointer: { x: number; y: number } | null = null;\n    let rings: Ring[] = [];\n    let frameId = 0;\n    let last = 0;\n    let visible = true;\n    let dim = \"\";\n    let lit = \"\";\n\n    const readColors = () => {\n      // Computed colors resolve light-dark(), which a raw custom property doesn't.\n      dim = getComputedStyle(canvas).color;\n      lit = getComputedStyle(wrap).color;\n    };\n\n    const layout = () => {\n      const dpr = window.devicePixelRatio || 1;\n      width = wrap.clientWidth;\n      height = wrap.clientHeight;\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const cols = Math.floor(width / SPACING);\n      const rows = Math.floor(height / SPACING);\n      // Centre the lattice so the margins match on every side.\n      const startX = (width - (cols - 1) * SPACING) / 2;\n      const startY = (height - (rows - 1) * SPACING) / 2;\n      count = cols * rows;\n      baseX = new Float32Array(count);\n      baseY = new Float32Array(count);\n      offX = new Float32Array(count);\n      offY = new Float32Array(count);\n      velX = new Float32Array(count);\n      velY = new Float32Array(count);\n      size = new Float32Array(count);\n      velSize = new Float32Array(count);\n      for (let r = 0, i = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++, i++) {\n          baseX[i] = startX + c * SPACING;\n          baseY[i] = startY + r * SPACING;\n        }\n      }\n    };\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = dim;\n      // Resting dots sit back as texture; the lit pass supplies the contrast.\n      ctx.globalAlpha = 0.5;\n      ctx.beginPath();\n      for (let i = 0; i < count; i++) {\n        const x = baseX[i] + offX[i];\n        const y = baseY[i] + offY[i];\n        const r = DOT * (1 + (reduce ? 0 : GROW) * size[i]);\n        ctx.moveTo(x + r, y);\n        ctx.arc(x, y, r, 0, Math.PI * 2);\n      }\n      ctx.fill();\n      // Only the few energised dots get a second, brighter pass.\n      ctx.fillStyle = lit;\n      for (let i = 0; i < count; i++) {\n        const s = size[i];\n        if (s < 0.02) continue;\n        const x = baseX[i] + offX[i];\n        const y = baseY[i] + offY[i];\n        const r = DOT * (1 + (reduce ? 0 : GROW) * s);\n        ctx.globalAlpha = Math.min(1, s);\n        ctx.beginPath();\n        ctx.arc(x, y, r, 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const step = (now: number) => {\n      // Clamped so a dropped frame or a background tab can't explode the spring.\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      const t = now / 1000;\n      rings = rings.filter(\n        (ring) => (t - ring.born) * RING_SPEED < Math.hypot(width, height) + RING_WIDTH,\n      );\n\n      let moving = rings.length > 0;\n      for (let i = 0; i < count; i++) {\n        let targetX = 0;\n        let targetY = 0;\n        let targetSize = 0;\n        if (pointer) {\n          const dx = baseX[i] - pointer.x;\n          const dy = baseY[i] - pointer.y;\n          const d = Math.hypot(dx, dy);\n          if (d < FIELD) {\n            const f = (1 - d / FIELD) ** 2;\n            targetSize = f;\n            if (!reduce && d > 0.001) {\n              targetX = (dx / d) * PUSH * f;\n              targetY = (dy / d) * PUSH * f;\n            }\n          }\n        }\n        for (const ring of rings) {\n          const dx = baseX[i] - ring.x;\n          const dy = baseY[i] - ring.y;\n          const d = Math.hypot(dx, dy);\n          const radius = (t - ring.born) * RING_SPEED;\n          const band = Math.exp(-(((d - radius) / RING_WIDTH) ** 2));\n          // The ring weakens as it spreads, like a real shockwave.\n          const fade = Math.max(0, 1 - radius / Math.hypot(width, height));\n          const f = band * fade;\n          if (f < 0.01 || d < 0.001) continue;\n          targetX += (dx / d) * RING_PUSH * f;\n          targetY += (dy / d) * RING_PUSH * f;\n          targetSize = Math.max(targetSize, f);\n        }\n\n        velX[i] += (STIFFNESS * (targetX - offX[i]) - DAMPING * velX[i]) * dt;\n        velY[i] += (STIFFNESS * (targetY - offY[i]) - DAMPING * velY[i]) * dt;\n        velSize[i] += (STIFFNESS * (targetSize - size[i]) - DAMPING * velSize[i]) * dt;\n        offX[i] += velX[i] * dt;\n        offY[i] += velY[i] * dt;\n        size[i] = Math.max(0, size[i] + velSize[i] * dt);\n\n        // Settled means at its target, not at home: a resting cursor costs nothing.\n        if (\n          Math.abs(targetX - offX[i]) > REST ||\n          Math.abs(targetY - offY[i]) > REST ||\n          Math.abs(targetSize - size[i]) > REST ||\n          Math.abs(velX[i]) > REST ||\n          Math.abs(velY[i]) > REST ||\n          Math.abs(velSize[i]) > REST\n        ) {\n          moving = true;\n        }\n      }\n      draw();\n      frameId = moving && visible ? requestAnimationFrame(step) : 0;\n    };\n\n    // Sleeps when everything settles; any input wakes it.\n    const wake = () => {\n      if (frameId || !visible) return;\n      last = performance.now();\n      frameId = requestAnimationFrame(step);\n    };\n\n    const local = (e: PointerEvent) => {\n      const box = canvas.getBoundingClientRect();\n      return { x: e.clientX - box.left, y: e.clientY - box.top };\n    };\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      pointer = local(e);\n      wake();\n    };\n    const onLeave = () => {\n      pointer = null;\n      wake();\n    };\n    const onDown = (e: PointerEvent) => {\n      if (reduce || e.button !== 0) return;\n      // Three rings at once is already a lot; older ones make way.\n      rings = [...rings.slice(-2), { ...local(e), born: performance.now() / 1000 }];\n      wake();\n    };\n\n    readColors();\n    layout();\n    draw();\n\n    const resize = new ResizeObserver(() => {\n      layout();\n      draw();\n    });\n    resize.observe(wrap);\n\n    const repaint = () =>\n      requestAnimationFrame(() => {\n        readColors();\n        if (!frameId) draw();\n      });\n    const theme = new MutationObserver(repaint);\n    theme.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"data-theme\"],\n    });\n    const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    scheme.addEventListener(\"change\", repaint);\n\n    // Offscreen, the loop pauses entirely.\n    const seen = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) wake();\n      else if (frameId) {\n        cancelAnimationFrame(frameId);\n        frameId = 0;\n      }\n    });\n    seen.observe(wrap);\n\n    canvas.addEventListener(\"pointermove\", onMove);\n    canvas.addEventListener(\"pointerleave\", onLeave);\n    canvas.addEventListener(\"pointerdown\", onDown);\n\n    return () => {\n      cancelAnimationFrame(frameId);\n      resize.disconnect();\n      theme.disconnect();\n      seen.disconnect();\n      scheme.removeEventListener(\"change\", repaint);\n      canvas.removeEventListener(\"pointermove\", onMove);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n      canvas.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [reduce]);\n\n  return (\n    <div\n      ref={wrapRef}\n      className={cn(\n        \"relative aspect-[3/2] w-[min(480px,100%)] overflow-hidden rounded-2xl bg-muted text-foreground shadow-raised\",\n        className,\n      )}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"absolute inset-0 size-full touch-manipulation text-muted-foreground\"\n      />\n      <p className=\"sr-only\">\n        A decorative grid of dots that swell and move away from the pointer.\n        Clicking sends a ring outward through the grid.\n      </p>\n    </div>\n  );\n}\n\nexport default function DotGridDemo() {\n  return <DotGrid />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/dot-grid"}