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;
  /**
   * Which click sound to play, or `false` for silence.
   *
   * Every CTA in the app funnels through this component, so this is where the
   * bulk of the site's feedback comes from. Callers that read as lighter than a
   * button (a nav row, a list item) should pass `"tick"`.
   */
  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 });

    /** Fires before the caller's handler, so the click is heard as the press lands. */
    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;

      // Calculate distance from center
      const deltaX = e.clientX - centerX;
      const deltaY = e.clientY - centerY;

      // Apply magnetic effect
      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
      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;
      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;