magnetic button.

A button that drifts toward the cursor as it passes over, then eases back to center. Plain React state and a CSS transform, no animation library.

react / motion / interaction

Loading component...

live component, try it

dependencies.

what to expect.

Hover the button to see it track the cursor
Move away and watch it ease back to center
Try moving your cursor slowly vs quickly

usage.

import MagneticButton from "@/components/ui/magnetic-button";

export default function MyPage() {
  return (
    <MagneticButton
      ariaLabel="Click me"
      className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-foreground bg-background border border-border rounded-lg hover:bg-muted transition-all shadow-sm"
    >
      Hover me
    </MagneticButton>
  );
}

the component.

"use client";

import {
  useRef,
  useState,
  ReactNode,
  forwardRef,
  isValidElement,
  cloneElement,
  type ButtonHTMLAttributes,
  type CSSProperties,
  type MouseEventHandler,
  type ReactElement,
} from "react";

import { feedback, type FeedbackKind } from "@/lib/feedback";

interface MagneticButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
  children: ReactNode;
  className?: string;
  maxDistance?: number;
  onClick?: MouseEventHandler<HTMLElement>;
  ariaLabel?: string;
  asChild?: boolean;
  sound?: FeedbackKind | false;
}

const MagneticButton = forwardRef<HTMLElement, MagneticButtonProps>(
  (
    {
      children,
      className = "",
      maxDistance = 15,
      onClick,
      ariaLabel,
      disabled = false,
      asChild = false,
      sound = "click",
      ...buttonProps
    },
    externalRef
  ) => {
    const internalRef = useRef<HTMLElement>(null);
    const [position, setPosition] = useState({ x: 0, y: 0 });

    const playFeedback = () => {
      if (sound !== false && !disabled) feedback(sound);
    };

    const handleMouseMove = (e: React.MouseEvent<HTMLElement>) => {
      const button = internalRef.current;
      if (!button || disabled) return;

      const rect = button.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;

      const deltaX = e.clientX - centerX;
      const deltaY = e.clientY - centerY;

      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
      // Pull grows with distance from centre, capped at maxDistance.
      const strength = Math.min(distance / rect.width, 1);

      setPosition({
        x: (deltaX / distance) * strength * maxDistance || 0,
        y: (deltaY / distance) * strength * maxDistance || 0,
      });
    };

    const handleMouseLeave = () => {
      setPosition({ x: 0, y: 0 });
    };

    const setRef = (node: HTMLElement | null) => {
      internalRef.current = node;
      // Keep an internal ref for measuring while still forwarding the caller's ref.
      if (typeof externalRef === "function") {
        externalRef(node);
      } else if (externalRef) {
        externalRef.current = node;
      }
    };

    const magneticStyle: CSSProperties = {
      transform: `translate(${position.x}px, ${position.y}px)`,
    };
    const magneticClassName = `cursor-pointer link transition-transform duration-200 ease-out ${className}`;

    if (asChild && isValidElement(children)) {
      const child = children as ReactElement<{
        className?: string;
        style?: CSSProperties;
        onClick?: MouseEventHandler<HTMLElement>;
        onMouseMove?: React.MouseEventHandler<HTMLElement>;
        onMouseLeave?: React.MouseEventHandler<HTMLElement>;
      }>;

      return cloneElement(child, {
        ref: setRef,
        className: `${magneticClassName} ${child.props.className ?? ""}`,
        style: { ...child.props.style, ...magneticStyle },
        onClick: (e: React.MouseEvent<HTMLElement>) => {
          playFeedback();
          child.props.onClick?.(e);
          onClick?.(e);
        },
        onMouseMove: (e: React.MouseEvent<HTMLElement>) => {
          child.props.onMouseMove?.(e);
          handleMouseMove(e);
        },
        onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
          child.props.onMouseLeave?.(e);
          handleMouseLeave();
        },
        "aria-label": ariaLabel,
        ...buttonProps,
      } as never);
    }

    return (
      <button
        ref={setRef as React.Ref<HTMLButtonElement>}
        className={magneticClassName}
        onClick={(e) => {
          playFeedback();
          onClick?.(e);
        }}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        aria-label={ariaLabel}
        disabled={disabled}
        style={magneticStyle}
        {...buttonProps}
      >
        {children}
      </button>
    );
  }
);

MagneticButton.displayName = "MagneticButton";

export default MagneticButton;

© 2026 Made with ❤️ by Ashwin S Nambiar

Check out v1, v3.