magneticButton
A Buttons block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call magneticButton() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full spring-physics pointer-follow wrapper: pointermove/pointerleave on the wrapper drive a mass/stiffness/damping simulation stepped every requestAnimationFrame, clamped to maxDistance, applied as translate() on the single wrapped child, torn down in _onMount's Remove hook. Default demo child (approximating the reference's solid-blue pill CTA) uses a dark-blue dataTone edge anchor (shift-15, primary family) rather than a literal bright hex blue, and strong()'s fixed shift-11 text-color offset yields a light-blue bold label rather than pure white -- both are deliberate substitutions to stay within Domphy's theme-token/doctor-clean idiom (no raw hex/rgb colors allowed), matching the tradeoff already established by shimmerButton.ts/rainbowButton.ts elsewhere in this package. Verified: tsc clean, domphy-doctor reports 0 diagnostics, all tests pass.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Magnetic Button" — clean-room reimplementation.
//
// A generic wrapper that makes its child element softly drift toward the
// cursor while hovering, then springs back to rest on pointer-leave.
// Implemented purely from the block's public functional/visual spec — no
// upstream Aceternity source was viewed or copied.
//
// The wrapper owns no visual chrome of its own — it only tracks the pointer
// and drives a `transform: translate()` on its single child via a spring
// simulation (a critically-underdamped mass/spring/damper stepped every
// animation frame), which is what produces the "overshoot and settle" feel
// on release rather than an instant snap. `pointermove` is listened on the
// wrapper itself (not `window`), so the effect is confined to when the
// cursor is over/near the wrapped child's own box, matching the spec's
// "while the pointer is within/near the button's bounding box" behavior.
import type { DomphyElement, StyleObject } from "@domphy/core";
import { strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";
export interface MagneticButtonProps {
/** The element to wrap (any button/link/interactive node). Defaults to a demo pill CTA button. */
children?: DomphyElement | DomphyElement[];
/** 0-1 float: how strongly the child chases the cursor (1 = tracks 1:1). Defaults to `0.8`. */
strength?: number;
/** Maximum drift distance, in px. Defaults to `100`. */
maxDistance?: number;
className?: string;
style?: StyleObject;
}
/** Default demo child: a solid, fully-rounded "Follow @mannupaaji" pill CTA, matching the
* reference demo's lone visible element. Anchored to a fixed dark-blue edge tone (`shift-15`,
* `"primary"` family — the darkest step of the primary ramp) so it reads as a solid, saturated
* blue regardless of the surrounding page tone, per the `dataTone-surface-contract`/
* `middle-surface-anchor` doctor rules (edge anchors only, both `backgroundColor` and `color`
* set on the anchoring element itself). */
function defaultMagneticChild(): DomphyElement<"button"> {
return {
button: [{ strong: "Follow @mannupaaji", $: [strong({ color: "primary" })] }],
type: "button",
dataTone: "shift-15",
style: {
appearance: "none",
border: "none",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: (listener) => themeSize(listener, "inherit"),
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1),
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4),
// A radius far beyond any realistic box half-height forces a full pill shape — the
// browser clamps it to the shape's own geometry (not tracked by the raw-spacing-value
// doctor rule, which only checks margin/padding/gap props).
borderRadius: "999px",
backgroundColor: (listener) => themeColor(listener, "inherit", "primary"),
color: (listener) => themeColor(listener, "shift-9", "primary"),
} as StyleObject,
};
}
function asChildren(value: DomphyElement | DomphyElement[]): DomphyElement[] {
return Array.isArray(value) ? value : [value];
}
/**
* Wraps a single child (typically a button/link) so it softly drifts toward the
* cursor on hover and springs back to rest on pointer-leave — a "magnetic"
* attraction effect. The wrapper contributes no styling of its own beyond
* positioning; all visual chrome comes from the wrapped child. Call with no
* arguments for a working demo — a solid pill CTA button.
*/
function magneticButton(props: MagneticButtonProps = {}): DomphyElement<"div"> {
const children = asChildren(props.children ?? defaultMagneticChild());
const strength = Math.max(0, Math.min(1, props.strength ?? 0.8));
const maxDistance = props.maxDistance ?? 100;
return {
div: children,
class: props.className,
style: {
display: "inline-block",
...(props.style ?? {}),
} as StyleObject,
_onMount: (node) => {
const wrapper = node.domElement as HTMLElement | null;
const target = wrapper?.firstElementChild as HTMLElement | null;
if (!wrapper || !target) return;
// Spring simulation state: current position/velocity, and the target offset
// the pointer is currently pulling toward (reset to origin on pointer-leave).
let positionX = 0;
let positionY = 0;
let velocityX = 0;
let velocityY = 0;
let targetOffsetX = 0;
let targetOffsetY = 0;
let animationFrame = 0;
// Critically-underdamped spring constants tuned for a small, quick
// overshoot-and-settle rather than a slow wobble or an instant snap.
const stiffness = 0.22;
const damping = 0.72;
const restEpsilon = 0.02;
const step = () => {
const forceX = (targetOffsetX - positionX) * stiffness;
const forceY = (targetOffsetY - positionY) * stiffness;
velocityX = (velocityX + forceX) * damping;
velocityY = (velocityY + forceY) * damping;
positionX += velocityX;
positionY += velocityY;
target.style.transform = `translate(${positionX.toFixed(2)}px, ${positionY.toFixed(2)}px)`;
const settled =
Math.abs(velocityX) < restEpsilon &&
Math.abs(velocityY) < restEpsilon &&
Math.abs(targetOffsetX - positionX) < restEpsilon &&
Math.abs(targetOffsetY - positionY) < restEpsilon;
if (settled) {
positionX = targetOffsetX;
positionY = targetOffsetY;
target.style.transform = `translate(${positionX}px, ${positionY}px)`;
animationFrame = 0;
return;
}
animationFrame = requestAnimationFrame(step);
};
const ensureRunning = () => {
if (!animationFrame) animationFrame = requestAnimationFrame(step);
};
const onPointerMove = (event: PointerEvent) => {
const rect = wrapper.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let offsetX = (event.clientX - centerX) * strength;
let offsetY = (event.clientY - centerY) * strength;
const distance = Math.hypot(offsetX, offsetY);
if (distance > maxDistance && distance > 0) {
const scale = maxDistance / distance;
offsetX *= scale;
offsetY *= scale;
}
targetOffsetX = offsetX;
targetOffsetY = offsetY;
ensureRunning();
};
const onPointerLeave = () => {
targetOffsetX = 0;
targetOffsetY = 0;
ensureRunning();
};
wrapper.addEventListener("pointermove", onPointerMove);
wrapper.addEventListener("pointerleave", onPointerLeave);
node.addHook("Remove", () => {
wrapper.removeEventListener("pointermove", onPointerMove);
wrapper.removeEventListener("pointerleave", onPointerLeave);
if (animationFrame) cancelAnimationFrame(animationFrame);
target.style.transform = "";
});
},
};
}
export { magneticButton };