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;
/**
* 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;