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…

"use client"

import * as React from "react"
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"
import { cn } from "@/lib/utils"

const SPRING = { stiffness: 180, damping: 15 }

// motion.button redefines a handful of native event handlers (drag/animation
// lifecycle) with its own gesture-aware signatures — omit those so spreading
// the rest of the native button attributes onto it type-checks.
type NativeButtonProps = Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/magnetic-button.json

Prompt

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 radius can exceed the button's own size.
  • Spring easing — raw pointer targets land in useMotionValues first; useSpring is 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-motion the sensing area and every translate are skipped outright; the button stays exactly where it's placed while remaining fully clickable and focusable.

On This Page