Buttons
Magnetic Button
A button that drifts toward the cursor within a sensing radius, then springs back on leave.
Preview in your theme
Loading preview…
- Dependencies
- motion
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/magnetic-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MagneticButton" component using the
`motion` (Framer Motion) library.
Contract
- Export a forwardRef button extending React.ButtonHTMLAttributes<HTMLButtonElement>.
- strength?: number — max translation in px at the strongest pull point
(default 12).
- radius?: number — sensing radius in px, measured from the button's own
center (default 120).
- className, style and children pass through; ref forwards to the real <button>
DOM node (via useImperativeHandle, since the component needs its own internal
ref to read the bounding rect on every pointer move). A consumer `style` is
merged UNDER the spring translate (style={{ ...style, x, y }}), never spread
over it — a spread would silently replace the magnetic translate.
Behavior
- No wrapper element: the button renders alone. A padded, transparent sensing
box would still hit-test and swallow clicks and text selection on any
neighbour within `radius`. Instead, one effect adds a passive `window`
pointermove listener (ignoring pointerType "touch" — a finger scrolling past
is not a cursor) and throttles it with requestAnimationFrame: store the
latest clientX/Y, schedule at most one frame, and in that frame read the
button's own getBoundingClientRect, compute dx/dy from the cursor to the
button's center, and distance = Math.hypot(dx, dy).
- If distance >= radius (or ~0), set both x and y motion values to 0.
- Otherwise pull = 1 - distance / radius (closer to center = stronger
ratio), and set x = (dx / distance) * strength * pull, y = (dy /
distance) * strength * pull.
- Two useMotionValue(0) hold those raw targets; two useSpring(value, {
stiffness: 180, damping: 15 }) smooth them into the button's actual
translateX/Y via style={{ x: springX, y: springY }} on a motion.button.
- When the pointer leaves the window (a document `pointerout` whose
relatedTarget is null), set both raw motion values back to 0 — the spring
eases the bounce back to rest. The effect's cleanup removes both listeners,
cancels the pending frame and resets x/y too.
- Wrap the button's children in their own motion.span translated by
useTransform(springX/Y, v => v * -0.4) for a subtle reverse-direction text
parallax (classic "magnetic button" depth cue).
- useReducedMotion(): when true, the effect adds no listener at all and the
motion values stay at 0 — the button never moves, but click, keyboard focus
and disabled state stay fully functional.
- When the button is disabled, the effect likewise adds no listener (and
resets a pull already in progress) — a disabled button never tracks the
cursor.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the surface,
focus-visible:ring-ring + ring-offset for the focus ring, disabled:opacity-50
disabled:pointer-events-none for the disabled state. Merge the consumer's
className with cn(). rounded-lg px-6 py-2.5 text-sm font-medium base shape.
Customization levers
- strength / radius: turn these for a subtler or a more dramatic pull; keep
radius at least ~3x strength so the ramp from full pull to rest doesn't feel
abrupt. Sensing takes no layout or hit-test space, so radius can exceed the
gap to neighbours without stealing their clicks; two magnetic buttons closer
than radius both lean toward a cursor between them.
- Spring stiffness / damping: lower stiffness + higher damping reads as a
lazier float; higher stiffness + lower damping reads as a snappier magnet.
- Text parallax multiplier (-0.4): push toward -0.6/-0.8 for more separation
between the surface and the label, or drop the inner motion.span for a
single-layer pull.
- Pair with hover:bg-primary/90, a hover:scale-105, or a subtle shadow to add
visual weight without touching the tracking math.Concepts
- Cursor-proximity pull — the pull ratio is a function of distance to the button's own center, not a raw drag offset; it ramps up the closer the cursor gets and resets to 0 the moment it exits the sensing radius.
- Hit-test-free sensing — proximity is computed from a window pointer listener against the button's own rect, so the sensing radius occupies no box: neighbours inside it stay clickable and selectable, and
radiuscan exceed the button's own size. - Spring easing — raw pointer targets land in
useMotionValues first;useSpringis what actually drives the visible translate, giving both the pull-in and the bounce-back a physical, overshoot-free feel. - Text parallax — the label rides its own motion value at a fraction (
-0.4x) of the button's translate, drifting slightly opposite it for a layered, tactile depth cue. - Reduced-motion honesty — under
prefers-reduced-motionno listener is attached and every translate is skipped outright; the button stays exactly where it's placed while remaining fully clickable and focusable.