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.
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;