Skip to content
Uxelith
Sign in
Freev1.1.0Actions

Magnetic Button

A button with a gentle magnetic field: it leans toward the pointer as you come near, a few pixels at most, its label following a little further for depth, and settles back without a wobble.

pnpm dlx shadcn add …/r/magnetic-button.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/magnetic-button.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as magnetic-button.tsx. It's yours: change anything.

Usage

example.tsx
import { MagneticButton } from "@/components/magnetic-button"

export function Hero() {
  return <MagneticButton size="lg">Get started</MagneticButton>
}

Source

One file, 134 lines, nothing hidden. Read it before you install it.

magnetic-button.tsx
"use client";

import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface MagneticButtonProps extends Omit<React.ComponentProps<"button">, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"> {
  /** How far the button follows the pointer, from 0 (not at all) to 1 (all the way). */
  strength?: number;
  /** How far beyond its edge, in pixels, the button starts to feel the pointer. */
  radius?: number;
  variant?: "solid" | "outline" | "ghost";
  size?: "sm" | "md" | "lg";
  /** Render a link instead of a button, e.g. for a call to action that navigates. */
  href?: string;
}

const sizes = {
  sm: "h-9 px-4 text-sm",
  md: "h-11 px-6 text-sm",
  lg: "h-14 px-8 text-base",
} as const;

const variants = {
  solid: "bg-foreground text-background shadow-[0_8px_24px_-12px_rgb(0_0_0/0.5)] hover:shadow-[0_14px_34px_-14px_rgb(0_0_0/0.6)] hover:bg-foreground/90",
  outline: "border border-border bg-background/60 text-foreground backdrop-blur hover:bg-accent",
  ghost: "text-foreground hover:bg-accent",
} as const;

// Motion presets (ADR-010).
const FOLLOW_SMOOTH = { mass: 0.5, stiffness: 220, damping: 26 };

// However strong the pull, the button never strays further than this (px).
const MAX_PULL = 8;

/**
 * A button with a gentle magnetic field: it leans toward the pointer as you
 * come near, a few pixels at most, and its label leans a little further for
 * depth. It settles back without wobbling.
 *
 * - Only a mouse or pen moves it; touch and keyboard use it as a plain button.
 * - Still under `prefers-reduced-motion`.
 * - It is a real `<button>` (type="button" by default), or a real `<a>` when
 *   given `href`; movement is transform-only, so layout and hit testing never shift.
 */
export function MagneticButton({
  strength = 0.2,
  radius = 40,
  variant = "solid",
  size = "md",
  type = "button",
  href,
  disabled,
  className,
  children,
  ...props
}: MagneticButtonProps) {
  const ref = React.useRef<HTMLButtonElement>(null);
  const reduceMotion = useReducedMotion();
  const x = useSpring(0, FOLLOW_SMOOTH);
  const y = useSpring(0, FOLLOW_SMOOTH);
  const labelX = useTransform(x, (v) => v * 0.35);
  const labelY = useTransform(y, (v) => v * 0.35);
  const pull = Math.min(Math.max(strength, 0), 1);

  // The field reaches past the button, so listen on the window (one listener,
  // at most one read per frame) while a fine pointer is in use.
  React.useEffect(() => {
    const node = ref.current;
    if (!node || reduceMotion || disabled || pull === 0 || !window.matchMedia("(pointer: fine)").matches) return;
    let frame = 0;
    let pointer = { x: 0, y: 0 };
    const update = () => {
      frame = 0;
      const rect = node.getBoundingClientRect();
      // Where the button rests, not where it has been pulled to.
      const cx = rect.left + rect.width / 2 - x.get();
      const cy = rect.top + rect.height / 2 - y.get();
      const dx = pointer.x - cx;
      const dy = pointer.y - cy;
      const gap = Math.hypot(Math.max(Math.abs(dx) - rect.width / 2, 0), Math.max(Math.abs(dy) - rect.height / 2, 0));
      const t = Math.max(1 - gap / Math.max(radius, 1), 0);
      const force = t * t * (3 - 2 * t);
      const clamp = (v: number) => Math.max(-MAX_PULL, Math.min(MAX_PULL, v));
      x.set(clamp(dx * pull * force));
      y.set(clamp(dy * pull * force));
    };
    const onMove = (event: PointerEvent) => {
      if (event.pointerType === "touch") return;
      pointer = { x: event.clientX, y: event.clientY };
      if (!frame) frame = requestAnimationFrame(update);
    };
    const release = () => {
      x.set(0);
      y.set(0);
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    document.documentElement.addEventListener("pointerleave", release);
    window.addEventListener("blur", release);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("pointermove", onMove);
      document.documentElement.removeEventListener("pointerleave", release);
      window.removeEventListener("blur", release);
      release();
    };
  }, [reduceMotion, disabled, pull, radius, x, y]);


  // A link when it navigates, a button when it acts.
  const Root = (href ? motion.a : motion.button) as typeof motion.button;
  return (
    <Root
      ref={ref}
      {...(href ? { href } : { type, disabled })}
      style={{ x, y }}
      className={cn(
        "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium tracking-tight outline-none",
        "transition-[color,background-color,box-shadow,opacity,scale] duration-200 active:scale-[0.97] motion-reduce:active:scale-100",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:pointer-events-none disabled:opacity-50",
        variants[variant],
        sizes[size],
        className,
      )}
      {...props}
    >
      <motion.span style={{ x: labelX, y: labelY }} className="inline-flex items-center gap-2">
        {children}
      </motion.span>
    </Root>
  );
}

Props

PropTypeDefaultDescription
strengthnumber0.2How far it follows the pointer, from 0 to 1.
radiusnumber40How far beyond its edge, in pixels, it feels the pointer.
variant"solid" | "outline" | "ghost""solid"Visual style.
size"sm" | "md" | "lg""md"Height and padding.
hrefstring—Render a real link instead of a button.
...propsButtonHTMLAttributes—Any button attribute. type defaults to "button".

Accessibility

  • A real <button>: focus, Enter/Space and disabled come from the browser.
  • Movement is transform-only, so the hit area and layout never shift.
  • Only mouse and pen move it; touch and keyboard use it as a normal button.
  • Still under prefers-reduced-motion.

Changelog

  1. v1.1.0Latest

    New: href renders the button as a link, for calls to action that navigate.

More in Actions

All 4 in Actions
  • ActionsFree

    Copy Button

    Copies text to the clipboard: the icon morphs into a tick (or a cross that shakes if it failed) and a visible label reads "Copied". Announces the result and falls back where the Clipboard API is missing.

  • ActionsFree

    Gradient Button

    A call-to-action button with a tasteful gradient: neighbouring hues, a light that follows the pointer and a sheen that passes now and then. The outline style is quiet until a beam of light runs round its border.

  • ActionsFree

    Tooltip

    A small hint that springs out of the side facing its element on hover or keyboard focus. Group a toolbar's tooltips and neighbours open at once while one bubble glides between them. Dismissable and hoverable (WCAG 1.4.13).