liten

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.

Terminal
bun add motion

Copy the component into components/ui/magnetic-field.tsx.

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.

Example.tsx
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.

Example.tsx
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

PropTypeDefaultDescription
accentstring"#3ecf8e"Color the field bends toward near the pointer.
gapnumber16Lattice spacing in px. Smaller is denser.
radiusnumber140Pointer influence radius in px, the well's reach.
pullnumber0.42Pull strength 0..1, how far mid-field dots draw to the pointer.
dotSizenumber0.7Resting dot radius in px.
maskbooleantrueFade the edges with a radial mask so the lattice never hard-cuts.
classNamestring-Forwarded to the canvas. Size the field on its container.

Magnetic

PropTypeDefaultDescription
childrenReact.ReactNode-The element to magnetize.
strengthnumber0.35Fraction of the pointer offset the element follows, 0..1.
reachnumber24How far outside the element the pull reaches, in px.
depthnumber0.6How much the inner content trails the shell for parallax.
classNamestring-Forwarded to the wrapper.
On this page0%