{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnetic-dot-grid","type":"registry:component","title":"Magnetic Dot Grid","description":"A dot grid that parts around the cursor and springs back.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":[],"files":[{"path":"registry/dimaac/magnetic-dot-grid/magnetic-dot-grid.tsx","type":"registry:component","target":"components/dimaac/magnetic-dot-grid.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useSyncExternalStore, type ReactNode } from \"react\";\n\n// A full-bleed grid of dots on a 2D canvas. Every dot has a home spot and a spring that pulls it\n// back there. Near the cursor the dots are pushed away (or pulled in), swell and take the accent\n// colour; when the cursor leaves the springs carry them home with a little overshoot. A click\n// sends a shockwave ring through the grid. The loop sleeps when nothing is moving.\n\nexport type MagneticDotGridProps = {\n  /** Distance between dots, in pixels. */\n  gap?: number;\n  /** Radius of a dot at rest, in pixels. */\n  dotSize?: number;\n  /** How far the cursor reaches, in pixels. */\n  radius?: number;\n  /** \"repel\" pushes the dots away from the cursor, \"attract\" pulls them in. */\n  mode?: \"repel\" | \"attract\";\n  /** How hard each dot's spring pulls it home. Higher is snappier. */\n  stiffness?: number;\n  /** How quickly the springs stop wobbling. Lower overshoots more. */\n  damping?: number;\n  /** The colour dots take near the cursor and in a shockwave. Any CSS colour, including var(--x). */\n  accentColour?: string;\n  /** The colour of a dot at rest. \"currentColor\" follows the text colour around it. */\n  baseColour?: string;\n  /** How strong a resting dot's colour is, from 0 to 1, so the grid stays quiet behind content. */\n  baseOpacity?: number;\n  /** A click (or tap) sends a ring through the grid. */\n  shockwave?: boolean;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Content shown over the grid, e.g. a headline and a button. The grid keeps reacting under it. */\n  children?: ReactNode;\n};\n\ntype Dot = { homeX: number; homeY: number; x: number; y: number; vx: number; vy: number; heat: number };\ntype Ring = { x: number; y: number; age: number };\ntype Rgb = [number, number, number];\n\n// How many colour steps between base and accent. Dots are drawn in one path per step, which keeps\n// a few thousand dots cheap to draw.\nconst STEPS = 10;\nconst RING_SPEED = 1100; // px per second\nconst RING_WIDTH = 80; // px\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n// Any CSS colour (currentColor and var(--x) included) to [r, g, b]. A hidden probe inside the box\n// lets the browser resolve it in place, then the canvas normalises whatever format comes back.\nfunction resolveRgb(probe: HTMLElement, context: CanvasRenderingContext2D, colour: string): Rgb {\n  probe.style.color = \"\";\n  probe.style.color = colour;\n  const computed = getComputedStyle(probe).color;\n  context.fillStyle = \"#000\";\n  context.fillStyle = computed;\n  const value = String(context.fillStyle);\n  if (value.startsWith(\"#\")) {\n    const n = parseInt(value.slice(1, 7), 16);\n    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n  }\n  const parts = value.match(/[\\d.]+/g)?.map(Number) ?? [0, 0, 0];\n  return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n}\n\n// sRGB <-> OKLab. Blending in OKLab keeps the steps between ink and lime clean (an RGB blend runs\n// through muddy olive on the way).\nconst toLinear = (c: number) => {\n  const v = c / 255;\n  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;\n};\nconst fromLinear = (v: number) => {\n  const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.max(0, v) ** (1 / 2.4) - 0.055;\n  return Math.round(Math.min(1, Math.max(0, c)) * 255);\n};\nfunction rgbToOklab([r8, g8, b8]: Rgb): Rgb {\n  const r = toLinear(r8);\n  const g = toLinear(g8);\n  const b = toLinear(b8);\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n  return [\n    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n  ];\n}\nfunction oklabToRgb([L, A, B]: Rgb): Rgb {\n  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;\n  const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;\n  const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;\n  return [\n    fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),\n    fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),\n    fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),\n  ];\n}\n\n// 0 at the edge of the reach, 1 under the cursor, with soft ends\nfunction falloff(distance: number, reach: number) {\n  const t = Math.max(0, 1 - distance / reach);\n  return t * t * (3 - 2 * t);\n}\n\nexport function MagneticDotGrid({\n  gap = 24,\n  dotSize = 1.6,\n  radius = 160,\n  mode = \"repel\",\n  stiffness = 170,\n  damping = 13,\n  accentColour = \"#ddfc3e\",\n  baseColour = \"currentColor\",\n  baseOpacity = 0.32,\n  shockwave = true,\n  className = \"\",\n  children,\n}: MagneticDotGridProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const probeRef = useRef<HTMLSpanElement>(null);\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n  // The loop reads these every frame, so changing a prop never rebuilds the grid\n  const settings = useRef({ dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave });\n  // Set by the effect below: wakes the loop after a prop change\n  const wakeRef = useRef<() => void>(() => {});\n\n  useEffect(() => {\n    settings.current = { dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave };\n    wakeRef.current();\n  }, [dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave]);\n\n  useEffect(() => {\n    const box = boxRef.current;\n    const canvas = canvasRef.current;\n    const probe = probeRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!box || !canvas || !probe || !context) return;\n    const ctx = context;\n\n    let dots: Dot[] = [];\n    let rings: Ring[] = [];\n    let width = 0;\n    let height = 0;\n    let frame = 0;\n    let running = false;\n    let visible = true;\n    let lastTime = 0;\n    // The biggest change any dot made in the last frame, so the loop can sleep once nothing moves,\n    // even with the cursor resting on the grid\n    let motion = { speed: 0, offset: 0, heat: 0 };\n    // One fill style per colour step, worked out only when a colour, the theme or the size changes\n    let bucketStyles: string[] = [];\n\n    function computeColours() {\n      const { accentColour: accent, baseColour: base, baseOpacity: opacity } = settings.current;\n      const from = rgbToOklab(resolveRgb(probe!, ctx, base));\n      const to = rgbToOklab(resolveRgb(probe!, ctx, accent));\n      bucketStyles = [];\n      for (let index = 0; index <= STEPS; index++) {\n        const t = index / STEPS;\n        const [r, g, b] = oklabToRgb([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);\n        const alpha = opacity + (1 - opacity) * Math.min(1, t * 1.6);\n        bucketStyles.push(`rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`);\n      }\n    }\n\n    // The raw pointer, and a smoothed copy the dots actually react to, so fast flicks feel liquid\n    const pointer = { x: 0, y: 0, active: false };\n    const follow = { x: 0, y: 0, strength: 0 };\n\n    // Lay the dots out on a grid centred in the box, so the margins match on every side\n    function build() {\n      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);\n      width = Math.max(1, box!.clientWidth);\n      height = Math.max(1, box!.clientHeight);\n      canvas!.width = Math.round(width * pixelRatio);\n      canvas!.height = Math.round(height * pixelRatio);\n      ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);\n      computeColours();\n\n      const columns = Math.floor(width / gap) + 1;\n      const rows = Math.floor(height / gap) + 1;\n      const left = (width - (columns - 1) * gap) / 2;\n      const top = (height - (rows - 1) * gap) / 2;\n      dots = [];\n      for (let row = 0; row < rows; row++) {\n        for (let column = 0; column < columns; column++) {\n          const homeX = left + column * gap;\n          const homeY = top + row * gap;\n          dots.push({ homeX, homeY, x: homeX, y: homeY, vx: 0, vy: 0, heat: 0 });\n        }\n      }\n      wake();\n    }\n\n    // One physics step of dt seconds\n    function step(dt: number) {\n      const { radius: reach, mode: pull, stiffness: k, damping: c } = settings.current;\n      motion = { speed: 0, offset: 0, heat: 0 };\n\n      // The smoothed pointer catches up in about a tenth of a second, and fades in and out\n      const catchUp = 1 - Math.exp(-dt * 16);\n      follow.x += (pointer.x - follow.x) * catchUp;\n      follow.y += (pointer.y - follow.y) * catchUp;\n      follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));\n\n      for (const ring of rings) ring.age += dt;\n      rings = rings.filter((ring) => ring.age * RING_SPEED < Math.hypot(width, height) + RING_WIDTH);\n\n      for (const dot of dots) {\n        let targetX = dot.homeX;\n        let targetY = dot.homeY;\n        let warmth = 0;\n\n        if (follow.strength > 0.001) {\n          const dx = dot.homeX - follow.x;\n          const dy = dot.homeY - follow.y;\n          const distance = Math.hypot(dx, dy) || 0.0001;\n          const force = falloff(distance, reach) * follow.strength;\n          if (force > 0) {\n            // Repel opens a clearing about a third of the reach wide; attract gathers the dots\n            // toward the cursor but stops them a little over half a gap short, so they never pile up\n            const shift = pull === \"repel\" ? force * reach * 0.34 : -Math.min(force * reach * 0.3, Math.max(0, distance - gap * 0.6));\n            targetX += (dx / distance) * shift;\n            targetY += (dy / distance) * shift;\n            warmth = force;\n          }\n        }\n\n        // Each ring kicks the dots it passes outward and lights them up, fading as it travels\n        for (const ring of rings) {\n          const dx = dot.homeX - ring.x;\n          const dy = dot.homeY - ring.y;\n          const distance = Math.hypot(dx, dy) || 0.0001;\n          const front = ring.age * RING_SPEED;\n          const band = Math.max(0, 1 - Math.abs(distance - front) / RING_WIDTH);\n          if (band <= 0) continue;\n          const fade = Math.max(0, 1 - front / (Math.hypot(width, height) * 0.9));\n          const kick = band * band * fade * 5200 * dt;\n          dot.vx += (dx / distance) * kick;\n          dot.vy += (dy / distance) * kick;\n          warmth = Math.max(warmth, band * fade);\n        }\n\n        // A damped spring toward the target spot (semi-implicit Euler: stable at these steps)\n        dot.vx += (k * (targetX - dot.x) - c * dot.vx) * dt;\n        dot.vy += (k * (targetY - dot.y) - c * dot.vy) * dt;\n        dot.x += dot.vx * dt;\n        dot.y += dot.vy * dt;\n\n        // Heat rises fast and cools a little slower, so the colour leaves a short afterglow\n        const rate = warmth > dot.heat ? 14 : 4.5;\n        dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * rate));\n\n        motion.speed = Math.max(motion.speed, Math.abs(dot.vx), Math.abs(dot.vy));\n        motion.offset = Math.max(motion.offset, Math.abs(targetX - dot.x), Math.abs(targetY - dot.y));\n        motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));\n      }\n    }\n\n    // Reduced motion: no springs, no rings. The dots stay home and only the colour follows.\n    function stepStill(dt: number) {\n      const { radius: reach } = settings.current;\n      motion = { speed: 0, offset: 0, heat: 0 };\n      follow.x = pointer.x;\n      follow.y = pointer.y;\n      follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));\n      rings = [];\n      for (const dot of dots) {\n        dot.x = dot.homeX;\n        dot.y = dot.homeY;\n        dot.vx = 0;\n        dot.vy = 0;\n        const warmth = falloff(Math.hypot(dot.homeX - follow.x, dot.homeY - follow.y), reach) * follow.strength;\n        dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * 10));\n        motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));\n      }\n    }\n\n    // Colour buckets made once and emptied each frame, so long hovers make no garbage\n    const buckets: Dot[][] = Array.from({ length: STEPS + 1 }, () => []);\n\n    function draw() {\n      const { dotSize: size, mode: pull } = settings.current;\n      // Attract packs the dots closer, so they swell less there and never overlap\n      const swell = pull === \"attract\" ? 1 : 1.6;\n      ctx.clearRect(0, 0, width, height);\n\n      // Sort the dots into colour steps, then draw each step as one path\n      for (const bucket of buckets) bucket.length = 0;\n      for (const dot of dots) buckets[Math.min(STEPS, Math.round(dot.heat * STEPS))]!.push(dot);\n\n      buckets.forEach((bucket, index) => {\n        if (bucket.length === 0) return;\n        const t = index / STEPS;\n        // Hot dots swell to about 2.6 times their size (not in reduced motion: colour only)\n        const dotRadius = reducedMotion ? size : size * (1 + t * swell);\n        ctx.fillStyle = bucketStyles[index] ?? \"transparent\";\n        ctx.beginPath();\n        for (const dot of bucket) {\n          ctx.moveTo(dot.x + dotRadius, dot.y);\n          ctx.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2);\n        }\n        ctx.fill();\n      });\n    }\n\n    // Nothing left to animate: no rings, the smoothed pointer has caught up and no dot moved or\n    // changed colour last frame. With the cursor gone, every dot must also be home and cool.\n    function settled() {\n      if (rings.length > 0) return false;\n      if (Math.abs(pointer.x - follow.x) + Math.abs(pointer.y - follow.y) > 0.1) return false;\n      if (Math.abs((pointer.active ? 1 : 0) - follow.strength) > 0.002) return false;\n      if (motion.speed > 0.5 || motion.offset > 0.05 || motion.heat > 0.01) return false;\n      if (pointer.active) return true;\n      for (const dot of dots) {\n        if (Math.abs(dot.vx) > 0.5 || Math.abs(dot.vy) > 0.5 || Math.abs(dot.x - dot.homeX) > 0.05 || Math.abs(dot.y - dot.homeY) > 0.05 || dot.heat > 0.01) return false;\n      }\n      return true;\n    }\n\n    function tick(now: number) {\n      // Fixed 1/120 s physics steps, so the springs feel the same at 60 Hz and 120 Hz\n      let elapsed = Math.min((now - lastTime) / 1000, 1 / 20);\n      lastTime = now;\n      while (elapsed > 0) {\n        const dt = Math.min(elapsed, 1 / 120);\n        if (reducedMotion) stepStill(dt);\n        else step(dt);\n        elapsed -= dt;\n      }\n      draw();\n      if (settled()) {\n        // With the cursor gone, snap the last fractions home so the final frame is exact. With it\n        // resting on the grid, leave the dots where they are: the next move wakes the loop.\n        if (!pointer.active) {\n          for (const dot of dots) {\n            dot.x = dot.homeX;\n            dot.y = dot.homeY;\n            dot.heat = 0;\n          }\n          draw();\n        }\n        running = false;\n        return;\n      }\n      frame = requestAnimationFrame(tick);\n    }\n\n    function wake() {\n      if (!visible) return;\n      if (running) return;\n      running = true;\n      lastTime = performance.now();\n      frame = requestAnimationFrame(tick);\n    }\n    // Draw once even when asleep (a new colour or size shows straight away)\n    function wakeAndDraw() {\n      computeColours();\n      draw();\n      wake();\n    }\n    wakeRef.current = wakeAndDraw;\n\n    function place(clientX: number, clientY: number) {\n      const rect = box!.getBoundingClientRect();\n      pointer.x = clientX - rect.left;\n      pointer.y = clientY - rect.top;\n      // The first touch of a visit starts at the finger, not at the old spot\n      if (follow.strength < 0.01) {\n        follow.x = pointer.x;\n        follow.y = pointer.y;\n      }\n      pointer.active = true;\n      wake();\n    }\n\n    // Mouse and pen: pointer events. Touch: touch events, which keep firing while the page scrolls,\n    // so the grid follows the finger without blocking scroll.\n    function onPointerMove(event: PointerEvent) {\n      if (event.pointerType === \"touch\") return;\n      place(event.clientX, event.clientY);\n    }\n    function onPointerLeave(event: PointerEvent) {\n      if (event.pointerType === \"touch\") return;\n      pointer.active = false;\n      // The loop may be asleep with the cursor parked: wake it so the dots go home\n      wake();\n    }\n    function onTouch(event: TouchEvent) {\n      const touch = event.touches[0];\n      if (touch) place(touch.clientX, touch.clientY);\n    }\n    function onTouchEnd(event: TouchEvent) {\n      if (event.touches.length === 0) {\n        pointer.active = false;\n        wake();\n      }\n    }\n    // Click rather than pointerdown, so a finger that starts a scroll does not fire a ring. A click\n    // made with Enter or Space on a button inside the grid has detail 0 and sits at (0, 0): skip it.\n    function onClick(event: MouseEvent) {\n      if (!settings.current.shockwave || reducedMotion || event.detail === 0) return;\n      const rect = box!.getBoundingClientRect();\n      rings.push({ x: event.clientX - rect.left, y: event.clientY - rect.top, age: 0 });\n      if (rings.length > 4) rings.shift();\n      wake();\n    }\n\n    box.addEventListener(\"pointermove\", onPointerMove);\n    box.addEventListener(\"pointerdown\", onPointerMove);\n    box.addEventListener(\"pointerleave\", onPointerLeave);\n    box.addEventListener(\"touchstart\", onTouch, { passive: true });\n    box.addEventListener(\"touchmove\", onTouch, { passive: true });\n    box.addEventListener(\"touchend\", onTouchEnd);\n    box.addEventListener(\"touchcancel\", onTouchEnd);\n    box.addEventListener(\"click\", onClick);\n\n    // A new size lays the grid out again\n    const resizer = new ResizeObserver(build);\n    resizer.observe(box);\n\n    // No work while the box is off screen\n    const visibility = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n      if (visible) wakeAndDraw();\n      else {\n        cancelAnimationFrame(frame);\n        running = false;\n      }\n    });\n    visibility.observe(box);\n\n    // \"currentColor\" changes with the site theme, so redraw when the theme flips\n    const themeWatcher = new MutationObserver(wakeAndDraw);\n    themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n    const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    scheme.addEventListener(\"change\", wakeAndDraw);\n\n    build();\n\n    return () => {\n      cancelAnimationFrame(frame);\n      running = false;\n      wakeRef.current = () => {};\n      resizer.disconnect();\n      visibility.disconnect();\n      themeWatcher.disconnect();\n      scheme.removeEventListener(\"change\", wakeAndDraw);\n      box.removeEventListener(\"pointermove\", onPointerMove);\n      box.removeEventListener(\"pointerdown\", onPointerMove);\n      box.removeEventListener(\"pointerleave\", onPointerLeave);\n      box.removeEventListener(\"touchstart\", onTouch);\n      box.removeEventListener(\"touchmove\", onTouch);\n      box.removeEventListener(\"touchend\", onTouchEnd);\n      box.removeEventListener(\"touchcancel\", onTouchEnd);\n      box.removeEventListener(\"click\", onClick);\n    };\n  }, [gap, reducedMotion]);\n\n  return (\n    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 block h-full w-full\" />\n      {/* Resolves the colour props (currentColor, var(--x)) in place */}\n      <span ref={probeRef} aria-hidden=\"true\" className=\"pointer-events-none absolute h-0 w-0 overflow-hidden\" />\n      {children !== undefined && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n"}],"categories":["backgrounds","canvas","cursor","background","physics","hero"],"docs":"import { MagneticDotGrid } from \"@/components/dimaac/magnetic-dot-grid\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/magnetic-dot-grid","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/magnetic-dot-grid","credits":[]}}