Magnetic Field
A lattice of dots that bends toward the cursor like a magnetic well, brightening and lifting near the pointer and easing back when it leaves.
A lattice of dots that dimples toward the pointer. Each dot is drawn inward by an amount that peaks in the mid-field and falls off on a gaussian, so the grid bends into a well instead of collapsing to a point: dots right under the cursor barely move, distant dots stay put, and the ring between them does the pulling. Dots inside the well brighten toward the accent, grow, and catch a top-lit highlight, so the pointer sits in a raised ridge, not a glow. When the pointer leaves, the whole field eases back to its grid and the animation stops until you return.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/magnetic-field.tsx.
'use client';import * as React from 'react';import { motion, useMotionValue, useReducedMotion, useSpring } from 'motion/react';import { cn } from '@/lib/cn';const MAGNET_SPRING = { stiffness: 220, damping: 20, mass: 0.35 } as const;export type MagneticFieldProps = { accent?: string; gap?: number; radius?: number; pull?: number; dotSize?: number; mask?: boolean; className?: string;};function parseRgb(value: string): [number, number, number] { const m = value.match(/-?\d+(\.\d+)?/g); if (!m || m.length < 3) return [140, 140, 140]; return [Number(m[0]), Number(m[1]), Number(m[2])];}function parseHex(hex: string): [number, number, number] { let h = hex.replace('#', '').trim(); if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; const n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255];}const lerp = (a: number, b: number, t: number) => a + (b - a) * t;export function MagneticField({ accent = '#3ecf8e', gap = 16, radius = 140, pull = 0.42, dotSize = 0.7, mask = true, className,}: MagneticFieldProps) { const canvasRef = React.useRef<HTMLCanvasElement>(null); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const [ar, ag, ab] = parseHex(accent); let w = 0; let h = 0; let dpr = 1; let restX: number[] = []; let restY: number[] = []; let curX: number[] = []; let curY: number[] = []; const buildGrid = () => { const cols = Math.max(1, Math.round(w / gap)); const rows = Math.max(1, Math.round(h / gap)); const offX = (w - (cols - 1) * gap) / 2; const offY = (h - (rows - 1) * gap) / 2; restX = []; restY = []; for (let gy = 0; gy < rows; gy++) { for (let gx = 0; gx < cols; gx++) { restX.push(offX + gx * gap); restY.push(offY + gy * gap); } } curX = restX.slice(); curY = restY.slice(); }; const resize = () => { const rect = canvas.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); w = rect.width; h = rect.height; canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); buildGrid(); }; const readBase = () => parseRgb(getComputedStyle(canvas).color); let base = readBase(); let px = -9999; let py = -9999; let sx = -9999; let sy = -9999; let energy = 0; let active = false; const inv2sig2 = 1 / (2 * radius * radius); const draw = (charged: boolean) => { ctx.clearRect(0, 0, w, h); for (let i = 0; i < restX.length; i++) { const rx = restX[i]; const ry = restY[i]; let tx = rx; let ty = ry; let near = 0; if (charged && energy > 0.001) { const dx = sx - rx; const dy = sy - ry; const falloff = Math.exp(-(dx * dx + dy * dy) * inv2sig2); near = falloff * energy; const k = pull * near; tx = rx + dx * k; ty = ry + dy * k; } curX[i] += (tx - curX[i]) * 0.22; curY[i] += (ty - curY[i]) * 0.22; const r = dotSize * (1 + 2.4 * near); const cr = Math.round(lerp(base[0], ar, Math.min(1, near * 1.4))); const cg = Math.round(lerp(base[1], ag, Math.min(1, near * 1.4))); const cb = Math.round(lerp(base[2], ab, Math.min(1, near * 1.4))); const alpha = 0.3 + 0.7 * near; ctx.beginPath(); ctx.arc(curX[i], curY[i], r, 0, Math.PI * 2); ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`; ctx.fill(); if (near > 0.16) { ctx.beginPath(); ctx.arc(curX[i], curY[i] - r * 0.4, r * 0.62, 0, Math.PI * 2); ctx.fillStyle = `rgba(255,255,255,${0.28 * near})`; ctx.fill(); } } }; resize(); if (reduce) { draw(false); const roStatic = new ResizeObserver(() => { resize(); base = readBase(); draw(false); }); roStatic.observe(canvas); return () => roStatic.disconnect(); } let raf = 0; let running = false; const tick = () => { energy += ((active ? 1 : 0) - energy) * (active ? 0.16 : 0.08); sx += (px - sx) * 0.3; sy += (py - sy) * 0.3; draw(true); if (active || energy > 0.002) { raf = requestAnimationFrame(tick); } else { draw(false); running = false; } }; const start = () => { if (running) return; running = true; raf = requestAnimationFrame(tick); }; const rectOf = () => canvas.getBoundingClientRect(); const onMove = (e: PointerEvent) => { const rect = rectOf(); px = e.clientX - rect.left; py = e.clientY - rect.top; if (!active) { sx = px; sy = py; active = true; } start(); }; const onLeave = () => { active = false; start(); }; canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerleave', onLeave); canvas.addEventListener('pointercancel', onLeave); const ro = new ResizeObserver(() => { resize(); base = readBase(); if (!running) draw(true); }); ro.observe(canvas); draw(false); return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); canvas.removeEventListener('pointerleave', onLeave); canvas.removeEventListener('pointercancel', onLeave); }; }, [accent, gap, radius, pull, dotSize]); return ( <canvas ref={canvasRef} aria-hidden className={cn( 'h-full w-full touch-none select-none text-neutral-300 dark:text-neutral-700', mask && '[mask-image:radial-gradient(ellipse_92%_96%_at_50%_50%,black_40%,transparent_98%)]', className, )} /> );}export type MagneticProps = { children: React.ReactNode; strength?: number; reach?: number; depth?: number; className?: string;};export function Magnetic({ children, strength = 0.35, reach = 24, depth = 0.6, className,}: MagneticProps) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLSpanElement>(null); const x = useMotionValue(0); const y = useMotionValue(0); const sx = useSpring(x, MAGNET_SPRING); const sy = useSpring(y, MAGNET_SPRING); const onMove = (e: React.PointerEvent) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const relX = e.clientX - (rect.left + rect.width / 2); const relY = e.clientY - (rect.top + rect.height / 2); x.set(relX * strength); y.set(relY * strength); }; const reset = () => { x.set(0); y.set(0); }; if (reduce) { return <span className={cn('inline-flex', className)}>{children}</span>; } return ( <motion.span ref={ref} onPointerMove={onMove} onPointerLeave={reset} onPointerCancel={reset} style={{ x: sx, y: sy, padding: reach, margin: -reach }} className={cn('relative inline-flex', className)} > <MagneticInner sx={sx} sy={sy} depth={depth}> {children} </MagneticInner> </motion.span> );}function MagneticInner({ sx, sy, depth, children,}: { sx: ReturnType<typeof useSpring>; sy: ReturnType<typeof useSpring>; depth: number; children: React.ReactNode;}) { const ix = useMotionValue(0); const iy = useMotionValue(0); React.useEffect(() => { const ux = sx.on('change', (v) => ix.set(v * depth)); const uy = sy.on('change', (v) => iy.set(v * depth)); return () => { ux(); uy(); }; }, [sx, sy, ix, iy, depth]); return ( <motion.span style={{ x: ix, y: iy, display: 'inline-flex' }}> {children} </motion.span> );}Usage
Give it a sized, positioned container. The field fills it and reads pointer
motion directly, so keep it behind content with pointer-events-none on the
overlay, not the field.
import { MagneticField } from '@/components/ui/magnetic-field';
export default function Example() {
return (
<div className="relative h-[360px] w-full overflow-hidden">
<MagneticField className="absolute inset-0" />
</div>
);
}Examples
A denser lattice with a shorter reach and a harder pull reads as a crisper dimple.
Magnetic wraps a single control and gives it the same pull: it drifts toward
the pointer as the cursor nears and springs back when it leaves. It only moves
the element, press, focus, and the surface stay on the child, so it composes
onto any button, icon, or link without changing how it works.
The pull starts in a reach margin around the element, so it reads as
attraction rather than hover, and the inner content trails the shell by depth
for a small parallax lift. It works just as well on tight icon controls.
import { Magnetic } from '@/components/ui/magnetic-field';
export default function Example() {
return (
<Magnetic strength={0.4} reach={40}>
<button className="rounded-full px-5 py-2.5">Get started</button>
</Magnetic>
);
}Props
MagneticField
| Prop | Type | Default | Description |
|---|---|---|---|
accent | string | "#3ecf8e" | Color the field bends toward near the pointer. |
gap | number | 16 | Lattice spacing in px. Smaller is denser. |
radius | number | 140 | Pointer influence radius in px, the well's reach. |
pull | number | 0.42 | Pull strength 0..1, how far mid-field dots draw to the pointer. |
dotSize | number | 0.7 | Resting dot radius in px. |
mask | boolean | true | Fade the edges with a radial mask so the lattice never hard-cuts. |
className | string | - | Forwarded to the canvas. Size the field on its container. |
Magnetic
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | The element to magnetize. |
strength | number | 0.35 | Fraction of the pointer offset the element follows, 0..1. |
reach | number | 24 | How far outside the element the pull reaches, in px. |
depth | number | 0.6 | How much the inner content trails the shell for parallax. |
className | string | - | Forwarded to the wrapper. |