ui
FreeMagnet
Spring-physics magnetic hover that pulls children toward the cursor with 3D tilt and a light glare that tracks the pointer.
// preview
Hover me
// settings
Pull radius (px)
Pull strength
Tilt amount
Lift
// source
TSJS
TailwindCSS
/*!
* Magnet — a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/magnet
* MIT licensed — keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";
export interface MagnetProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/** Extra distance (px) around the element where the pull begins. */
padding?: number;
disabled?: boolean;
/** Higher = weaker pull (divisor on cursor offset). */
magnetStrength?: number;
/** Max tilt in degrees. Set to 0 to disable tilting. */
tiltStrength?: number;
/** Show a light sheen that follows the cursor across the surface. */
glare?: boolean;
/** Scale applied while the magnet is engaged. */
lift?: number;
/** Spring stiffness while tracking the cursor — higher snaps faster. */
stiffness?: number;
/** Spring damping while tracking — lower is looser. */
damping?: number;
wrapperClassName?: string;
innerClassName?: string;
}
// Smoothstep gives the pull a soft radial falloff instead of a hard edge.
const smoothstep = (t: number) => {
const c = Math.min(Math.max(t, 0), 1);
return c * c * (3 - 2 * c);
};
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
// Loose spring used when the cursor lets go, so the element
// overshoots and wobbles back into place instead of easing home.
const RELEASE_STIFFNESS = 0.055;
const RELEASE_DAMPING = 0.9;
export default function Magnet({
children,
padding = 100,
disabled = false,
magnetStrength = 2,
tiltStrength = 12,
glare = true,
lift = 1.03,
stiffness = 0.14,
damping = 0.72,
wrapperClassName = "",
innerClassName = "",
...props
}: MagnetProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const glareRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (disabled) return;
const wrapper = wrapperRef.current;
const inner = innerRef.current;
if (!wrapper || !inner) return;
const coarse = window.matchMedia(
"(max-width: 639px), (hover: none) and (pointer: coarse)"
);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (coarse.matches || reducedMotion.matches) return;
// Spring state lives outside React — no re-render per mousemove.
const current = { x: 0, y: 0, rx: 0, ry: 0, s: 1, g: 0 };
const target = { x: 0, y: 0, rx: 0, ry: 0, s: 1, g: 0 };
const velocity = { x: 0, y: 0, rx: 0, ry: 0, s: 0, g: 0 };
const glarePos = { x: 50, y: 50 };
let engaged = false;
let frame = 0;
let settled = true;
const keys = ["x", "y", "rx", "ry", "s", "g"] as const;
const tick = () => {
const k = engaged ? stiffness : RELEASE_STIFFNESS;
const d = engaged ? damping : RELEASE_DAMPING;
let energy = 0;
for (const key of keys) {
velocity[key] = (velocity[key] + (target[key] - current[key]) * k) * d;
current[key] += velocity[key];
energy += Math.abs(velocity[key]) + Math.abs(target[key] - current[key]);
}
inner.style.transform =
`translate3d(${current.x}px, ${current.y}px, 0) ` +
`rotateX(${current.rx}deg) rotateY(${current.ry}deg) scale(${current.s})`;
const glareEl = glareRef.current;
if (glareEl) {
glareEl.style.opacity = String(clamp(current.g, 0, 1));
glareEl.style.background = `radial-gradient(140% 140% at ${glarePos.x}% ${glarePos.y}%, rgba(255,255,255,0.32), rgba(255,255,255,0.08) 55%, transparent 80%)`;
}
if (energy < 0.005) {
settled = true;
return;
}
frame = requestAnimationFrame(tick);
};
const wake = () => {
if (settled) {
settled = false;
frame = requestAnimationFrame(tick);
}
};
const onMouseMove = (e: MouseEvent) => {
const { left, top, width, height } = wrapper.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const dx = e.clientX - centerX;
const dy = e.clientY - centerY;
const reachX = width / 2 + padding;
const reachY = height / 2 + padding;
const distance = Math.hypot(dx / reachX, dy / reachY);
const pull = smoothstep(1 - distance);
engaged = pull > 0.001;
target.x = (dx / magnetStrength) * pull;
target.y = (dy / magnetStrength) * pull;
// Tilt is normalized against the element itself (not the reach zone)
// so the surface visibly banks toward the cursor.
target.ry = clamp(dx / (width / 2), -1, 1) * tiltStrength * pull;
target.rx = clamp(-dy / (height / 2), -1, 1) * tiltStrength * pull;
target.s = 1 + (lift - 1) * pull;
target.g = pull;
glarePos.x = 50 + clamp(dx / (width / 2), -1, 1) * 50;
glarePos.y = 50 + clamp(dy / (height / 2), -1, 1) * 50;
wake();
};
window.addEventListener("mousemove", onMouseMove, { passive: true });
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(frame);
inner.style.transform = "";
if (glareRef.current) glareRef.current.style.opacity = "0";
};
}, [padding, disabled, magnetStrength, tiltStrength, lift, stiffness, damping]);
return (
<div
ref={wrapperRef}
className={`relative block ${wrapperClassName}`}
style={{ perspective: "800px" }}
{...props}
>
{/* Tip: give innerClassName the same border radius as your content
(e.g. rounded-2xl) so the glare clips to the rounded corners. */}
<div
ref={innerRef}
className={`relative size-full will-change-transform [transform-style:preserve-3d] ${innerClassName}`}
>
{children}
{glare && (
<div
ref={glareRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] opacity-0 mix-blend-overlay"
/>
)}
</div>
</div>
);
}
// install
npx shadcn@latest add "https://designpass.dev/r/Magnet-TS-TW.json"Need the license details? Read the component license.