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…
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 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).
Behavior
- Wrap the button in a relatively-positioned div. Give that wrapper
`margin: -radius` and `padding: radius` — padding extends its content box
(and therefore its mousemove sensing area) `radius` px past the button on
every side, while the equal negative margin cancels the size increase so
sibling layout never reflows.
- On mousemove over the wrapper, 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.
- On mouseleave, set both raw motion values back to 0 — the spring eases the
bounce back to rest.
- 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, skip the wrapper's negative-margin/padding
sensing area entirely and never update the motion values — the button
never moves, but click, keyboard focus and disabled state stay fully
functional.
- When the button is disabled, the mousemove handler is a no-op — 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. Also keep radius under half the gap to the nearest interactive
neighbor — sensing boxes overflow the layout box by radius on every side,
and overlapping boxes steal each other's pointer events (disabled buttons
skip the expansion entirely for this reason).
- 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.
- Padded sensing wrapper — an equal negative margin and padding on the outer wrapper grows the mousemove-listening area past the button's visible box without pushing sibling layout, so
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-motionthe sensing area and every translate are skipped outright; the button stays exactly where it's placed while remaining fully clickable and focusable.