Restrained magnetic hover that pulls its child a few pixels toward the cursor and springs back on leave, fine-pointer only.
npx shadcn@latest add @paragon/magnetic-area"use client";
import * as React from "react";
import { motion, useReducedMotion, useSpring } from "motion/react";
import { cn } from "@/lib/utils";
export interface MagneticAreaProps extends React.ComponentProps<"div"> {
/** Max translation toward the cursor in px. Clamped to 0–8. */
strength?: number;
/** Disables the pull entirely. */
static?: boolean;
}
/**
* Restrained magnetic hover for icon buttons and CTAs: the child translates
* toward the cursor — a few px at most, this is a lean, not a chase — and
* springs back to center on leave. Mouse pointers only (touch and coarse
* pointers see nothing), and reduced motion disables it. The pull scales
* with cursor distance from center, so it eases in rather than jumping.
*/
export function MagneticArea({
strength = 4,
static: isStatic = false,
className,
children,
onPointerMove,
onPointerLeave,
...props
}: MagneticAreaProps) {
const reducedMotion = useReducedMotion();
const max = Math.min(Math.max(strength, 0), 8);
const enabled = !isStatic && !reducedMotion && max > 0;
const x = useSpring(0, { stiffness: 350, damping: 30 });
const y = useSpring(0, { stiffness: 350, damping: 30 });
return (
<div
data-slot="magnetic-area"
className={cn("inline-flex", className)}
onPointerMove={(event) => {
onPointerMove?.(event);
if (!enabled || event.pointerType !== "mouse") return;
const rect = event.currentTarget.getBoundingClientRect();
const nx = (event.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
const ny = (event.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
x.set(Math.max(-1, Math.min(1, nx)) * max);
y.set(Math.max(-1, Math.min(1, ny)) * max);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
x.set(0);
y.set(0);
}}
{...props}
>
<motion.div className="inline-flex" style={{ x, y }}>
{children}
</motion.div>
</div>
);
}