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
"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, 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 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 no listener is attached and every translate is skipped outright; the button stays exactly where it's placed while remaining fully clickable and focusable.

On This Page