Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct

Components (19)

  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
    new
  • Image Trail
    new
  • Liquid Image
    new
  • Magnetic Dot Grid
    new
  • Pixel Trail
    new
  • Pixelated Text
    new
  • Simple Scramble
    new
  • Text Bounce
    new
  • Text Fluid
    pro
  • Text Roll
    new
  • Curve Image
    new
  • Elastic Stick
    pro
  • Infinite Gallery
    new
  • Infinite Parallax
    new
  • Infinite Zoom
    new
  • Scattered Scroll
    new
Atelier UI 0.7.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Magnetic Dot Grid

Magnetic Dot Grid

A 2D canvas-based interactive dot grid with magnetic cursor effect

Canvas
Tailwind CSS
https://atelier-ui.com/magnetic-dot-grid

Settings

dot-radius
0.6
spacing
16
strength
20
interaction-radius
500
snap-speed
9
return-speed
5
float-amplitude
1.8
float-speed
2
base-color
#000000
See the documentation below for more options.

CLI Install

npx atelier-ui add magnetic-dot-grid

Manual Install

npm install 
magnetic-dot-grid.tsx
"use client"

import { type ComponentRef, useEffect, useRef } from "react"
import { useFrameLoop } from "../../hooks/use-frame-loop"

const FALL_OFF = -3.0

type DotGrid = {
    baseX: Float32Array
    baseY: Float32Array
    currentX: Float32Array
    currentY: Float32Array
    seed: Float32Array
    count: number
}

export type MagneticDotGridProps = {
    dotRadius?: number
    spacing?: number
    strength?: number
    interactionRadius?: number
    snapSpeed?: number
    returnSpeed?: number
    floatAmplitude?: number
    floatSpeed?: number
    baseColor?: string
    centerColors?: string[]
    cycleSpeed?: number
    className?: string
}

function hexToRgb(hex: string): [number, number, number] {
    const v = parseInt(hex.replace("#", ""), 16)
    return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]
}

function createGrid(width: number, height: number, spacing: number): DotGrid {
    const cols = Math.floor(width / spacing) + 1
    const rows = Math.floor(height / spacing) + 1
    const count = cols * rows

    const offsetX = (width - (cols - 1) * spacing) / 2
    const offsetY = (height - (rows - 1) * spacing) / 2

    const baseX = new Float32Array(count)
    const baseY = new Float32Array(count)
    const currentX = new Float32Array(count)
    const currentY = new Float32Array(count)
    const seed = new Float32Array(count)

    let index = 0

    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            const x = offsetX + col * spacing
            const y = offsetY + row * spacing
            baseX[index] = x
            baseY[index] = y
            currentX[index] = x
            currentY[index] = y
            seed[index] = Math.random() * 1000
            index++
        }
    }

    return {
        baseX,
        baseY,
        currentX,
        currentY,
        count,
        seed,
    }
}

export function MagneticDotGrid({
    dotRadius = 0.6,
    spacing = 16,
    strength = 20,
    interactionRadius = 500,
    snapSpeed = 9,
    returnSpeed = 5,
    floatAmplitude = 1.8,
    floatSpeed = 2,
    baseColor = "#000000",
    centerColors = ["#BCBCBC"],
    cycleSpeed = 1.5,
    className,
}: MagneticDotGridProps) {
    const canvasRef = useRef<ComponentRef<"canvas">>(null)
    const gridRef = useRef<DotGrid | null>(null)
    const pointerRef = useRef({ x: 0, y: 0, active: false })
    const baseColorRef = useRef(hexToRgb(baseColor))
    const centerColorsRef = useRef(centerColors.map(hexToRgb))

    useEffect(() => {
        baseColorRef.current = hexToRgb(baseColor)
        centerColorsRef.current = centerColors.map(hexToRgb)
    }, [baseColor, centerColors])

    useFrameLoop((time, delta) => {
        const canvas = canvasRef.current
        if (!canvas) return
        const ctx = canvas.getContext("2d")
        const grid = gridRef.current
        if (!grid || !ctx) return

        const pointer = pointerRef.current
        const parsedCenterColors = centerColorsRef.current
        const parsedBaseColor = baseColorRef.current
        const progress = ((time * cycleSpeed) % 1) * parsedCenterColors.length
        const currentColorIndex = Math.floor(progress) % parsedCenterColors.length
        const nextColorIndex = (currentColorIndex + 1) % parsedCenterColors.length
        const blend = progress - Math.floor(progress)
        const currentColor = parsedCenterColors[currentColorIndex]
        const nextColor = parsedCenterColors[nextColorIndex]

        const centerR = currentColor[0] + (nextColor[0] - currentColor[0]) * blend
        const centerG = currentColor[1] + (nextColor[1] - currentColor[1]) * blend
        const centerB = currentColor[2] + (nextColor[2] - currentColor[2]) * blend

        ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)

        const interactionRadiusSq = interactionRadius * interactionRadius

        for (let i = 0; i < grid.count; i++) {
            const bx = grid.baseX[i]
            const by = grid.baseY[i]
            const seed = grid.seed[i]

            let targetX = bx
            let targetY = by

            let influence = 0

            if (pointer.active) {
                const dx = bx - pointer.x
                const dy = by - pointer.y
                const distSq = dx * dx + dy * dy

                if (distSq < interactionRadiusSq) {
                    const dist = Math.sqrt(distSq)
                    const normalizedDist = dist / interactionRadius

                    influence = Math.exp(FALL_OFF * normalizedDist * normalizedDist)

                    if (dist > 0.001) {
                        const force = influence * strength
                        targetX += (dx / dist) * force
                        targetY += (dy / dist) * force
                    }
                }
            }

            const amp = floatAmplitude * influence
            const floatX = Math.sin(time * floatSpeed + seed) * amp
            const floatY = Math.sin(time * floatSpeed * 0.6 + seed * 1.3) * amp

            targetX += floatX
            targetY += floatY

            const speed = influence > 0.01 ? snapSpeed : returnSpeed
            const factor = 1 - Math.exp(-speed * delta)

            grid.currentX[i] += (targetX - grid.currentX[i]) * factor
            grid.currentY[i] += (targetY - grid.currentY[i]) * factor

            const r = parsedBaseColor[0] + (centerR - parsedBaseColor[0]) * influence
            const g = parsedBaseColor[1] + (centerG - parsedBaseColor[1]) * influence
            const b = parsedBaseColor[2] + (centerB - parsedBaseColor[2]) * influence

            ctx.fillStyle = `rgb(${r},${g},${b})`
            ctx.fillRect(
                grid.currentX[i] - dotRadius,
                grid.currentY[i] - dotRadius,
                dotRadius * 2,
                dotRadius * 2,
            )
        }
    })

    useEffect(() => {
        const canvas = canvasRef.current
        if (!canvas) return

        const ctx = canvas.getContext("2d", { alpha: true })
        if (!ctx) return

        const updatePointer = (clientX: number, clientY: number) => {
            const canvas = canvasRef.current
            if (!canvas) return
            const rect = canvas.getBoundingClientRect()
            pointerRef.current.x = clientX - rect.left
            pointerRef.current.y = clientY - rect.top
            pointerRef.current.active = true
        }

        const handlePointerMove = (event: PointerEvent) => {
            updatePointer(event.clientX, event.clientY)
        }

        const handlePointerLeave = () => {
            pointerRef.current.active = false
        }

        const displayGrid = () => {
            const dpr = window.devicePixelRatio || 1
            const width = canvas.clientWidth
            const height = canvas.clientHeight

            canvas.width = width * dpr
            canvas.height = height * dpr
            ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
            gridRef.current = createGrid(width, height, spacing)
        }

        displayGrid()
        const observer = new ResizeObserver(displayGrid)
        observer.observe(canvas)
        canvas.addEventListener("pointermove", handlePointerMove)
        canvas.addEventListener("pointerleave", handlePointerLeave)

        return () => {
            observer.disconnect()
            canvas.removeEventListener("pointermove", handlePointerMove)
            canvas.removeEventListener("pointerleave", handlePointerLeave)
        }
    }, [spacing])

    return (
        <canvas
            ref={canvasRef}
            className={className ?? "absolute inset-0 touch-none w-full h-full"}
        />
    )
}
use-frame-loop.ts
import { useEffect, useRef } from "react"

const DELTA_MAX = 0.1

type FrameLoopCallback = (time: number, delta: number) => void

export function useFrameLoop(callback: FrameLoopCallback, interval?: number) {
    const ref = useRef(callback)
    ref.current = callback

    useEffect(() => {
        let frameId = 0
        let lastTime = 0
        let lastTick = 0

        const tick = (now: number) => {
            frameId = requestAnimationFrame(tick)

            if (interval && now - lastTick < interval) return
            if (interval) lastTick = now

            const time = now * 0.001
            const delta = lastTime ? Math.min(time - lastTime, DELTA_MAX) : 0
            lastTime = time

            ref.current(time, delta)
        }

        frameId = requestAnimationFrame(tick)

        return () => {
            cancelAnimationFrame(frameId)
        }
    }, [interval])
}

API

NameTypeDefaultDescription
dotRadiusnumber0.6Radius of each dot in px.
spacingnumber16Distance between dots in px.
strengthnumber20Strength of the magnetic force in px.
interactionRadiusnumber500Cursor influence range in px.
snapSpeednumber9How fast dots react to the cursor.
returnSpeednumber5How slowly dots return to their base position.
floatAmplitudenumber1.8How far dots floats from the cursor.
floatSpeednumber2Speed of the floating fx.animation.
baseColorstring"#000000"Default dot color.
centerColorsstring[]["#BCBCBC"]Colors near the cursor. Cycles through the array.
cycleSpeednumber1.5Color cycle speed.
classNamestring"absolute inset-0 touch-none w-full h-full"Canvas element classes for positioning and sizing.

Credits

Google Stitch
Re-creation of the canvas hero effect (as of March 22, 2026). Enable dark mode to see the similarity.

  • CLI Install
  • Manual Install
  • API
  • Credits
Star on githubBuy me a coffeellms.txt