import lerp from "@phucbm/lerp";
/**
* Configuration options for the magnetic button effect
*/
export interface MagneticButtonOptions {
/** CSS class added when the magnetic effect is active */
activeClass?: string;
/** Controls the strength of the magnetic pull (0 = weak, 1 = strong) */
attraction?: number;
/** Defines the range within which the magnetic effect is active (in pixels) */
distance?: number;
/** Controls the speed of the magnetic movement (0 = slow, 1 = instant) */
speed?: number;
/** Disable magnetic effect on touch devices (default: true) */
disableOnTouch?: boolean;
/** Callback fired when mouse enters the magnetic area */
onEnter?: (data: MagneticData) => void;
/** Callback fired when mouse exits the magnetic area */
onExit?: (data: MagneticData) => void;
/** Callback fired continuously while mouse is in the magnetic area */
onUpdate?: (data: MagneticData) => void;
/** Maximum horizontal movement in pixels (optional constraint) */
maxX?: number | undefined;
/** Maximum vertical movement in pixels (optional constraint) */
maxY?: number | undefined;
}
/**
* Data object containing magnetic effect calculations
*/
export interface MagneticData {
/** Horizontal offset from element center */
deltaX: number;
/** Vertical offset from element center */
deltaY: number;
/** Distance between mouse and element center */
distance: number;
}
/**
* Position coordinates for interpolation
*/
interface LerpPosition {
x: number;
y: number;
}
/**
* MagneticButton class creates magnetic attraction effects for HTML elements
*
* Usage:
* ```html
*
* ```
*
* ```typescript
* import { MagneticButton } from 'magnetic-button';
*
* // Auto-initialize all elements with data-magnetic attribute
* new MagneticButton();
*
* // Or target specific element
* const button = document.querySelector('.my-button');
* new MagneticButton(button, { distance: 200, attraction: 0.5 });
* ```
*/
export class MagneticButton {
private readonly settings: Required = {
activeClass: 'magnetizing',
attraction: 0.3,
distance: 50,
speed: 0.1,
disableOnTouch: true,
// @ts-ignore
maxX: undefined,
// @ts-ignore
maxY: undefined,
onEnter: () => {
},
onExit: () => {
},
onUpdate: () => {
},
};
private isEnter: boolean = false;
private lerpPos: LerpPosition = {x: 0, y: 0};
private target: HTMLElement | null = null;
private boundMagnetize: ((e: MouseEvent) => void) | null = null;
// Track initialized elements to avoid duplicates
private static initializedElements = new WeakSet();
/**
* Detects if the device is primarily a touch device
* @returns true if the device uses touch as primary input
*/
private static isTouchDevice(): boolean {
// Check if primary input is touch (coarse pointer without hover capability)
return window.matchMedia("(hover: none) and (pointer: coarse)").matches;
}
/**
* Creates a new MagneticButton instance
* @param target - The HTML element to apply magnetic effect to. If null, auto-initializes all elements with data-magnetic attribute
* @param options - Configuration options for the magnetic effect
*/
constructor(target?: HTMLElement | null, options: MagneticButtonOptions = {}) {
// If no target is provided, select all elements with data-magnetic attribute
if (!target) {
document.querySelectorAll('[data-magnetic]').forEach(element => {
// Skip if already initialized
if (!MagneticButton.initializedElements.has(element)) {
new MagneticButton(element, options);
}
});
return; // Exit constructor if initializing multiple instances
}
// Skip if this element is already initialized
if (MagneticButton.initializedElements.has(target)) {
return;
}
// Mark as initialized
MagneticButton.initializedElements.add(target);
// Extract and validate data attributes
const dataDistance = parseFloat(target.getAttribute('data-distance') || '');
const dataAttraction = parseFloat(target.getAttribute('data-attraction') || '');
const dataSpeed = parseFloat(target.getAttribute('data-speed') || '');
const dataMaxX = parseFloat(target.getAttribute('data-max-x') || '');
const dataMaxY = parseFloat(target.getAttribute('data-max-y') || '');
// Merge default settings with options and data attributes
this.settings = {
...this.settings,
attraction: !isNaN(dataAttraction) ? dataAttraction : options.attraction ?? this.settings.attraction,
distance: !isNaN(dataDistance) ? dataDistance : options.distance ?? this.settings.distance,
speed: !isNaN(dataSpeed) ? dataSpeed : options.speed ?? this.settings.speed,
maxX: !isNaN(dataMaxX) ? dataMaxX : options.maxX ?? this.settings.maxX,
maxY: !isNaN(dataMaxY) ? dataMaxY : options.maxY ?? this.settings.maxY,
...options,
};
// Skip initialization on touch devices if disableOnTouch is true
if (this.settings.disableOnTouch && MagneticButton.isTouchDevice()) {
// Remove from initialized elements so it can be retried if needed
MagneticButton.initializedElements.delete(target);
return;
}
this.target = target;
this.boundMagnetize = (e: MouseEvent) => this.magnetize(target, e);
// Watch for mouse move events
window.addEventListener('mousemove', this.boundMagnetize);
// Add identification class
target.classList.add('is-magnetized');
}
/**
* Main magnetization logic - processes mouse movement and applies magnetic effect
* @param target - The target element
* @param e - Mouse event
*/
private magnetize(target: HTMLElement, e: MouseEvent): void {
const data = this.calculateCoordinates(target, e.clientX, e.clientY);
if (data.distance < this.settings.distance) {
// Mouse is inside magnetized area
this.animateButton(target, data.deltaX, data.deltaY);
if (!this.isEnter) {
this.isEnter = true;
target.classList.add(this.settings.activeClass);
this.settings.onEnter(data);
}
this.settings.onUpdate(data);
} else {
// Mouse is outside - return to origin
this.animateButton(target, 0, 0);
if (this.isEnter) {
this.isEnter = false;
target.classList.remove(this.settings.activeClass);
this.settings.onExit(data);
}
}
}
/**
* Applies smooth animation to the button using transform
* @param target - The target element
* @param endX - Target X position
* @param endY - Target Y position
*/
private animateButton(target: HTMLElement, endX: number, endY: number): void {
// Get interpolated position values
this.lerpPos.x = lerp(this.lerpPos.x, endX, this.settings.speed);
this.lerpPos.y = lerp(this.lerpPos.y, endY, this.settings.speed);
// Apply constraints if defined
let finalX = this.lerpPos.x;
let finalY = this.lerpPos.y;
if (this.settings.maxX !== undefined) {
finalX = Math.max(-this.settings.maxX, Math.min(this.settings.maxX, finalX));
}
if (this.settings.maxY !== undefined) {
finalY = Math.max(-this.settings.maxY, Math.min(this.settings.maxY, finalY));
}
// Apply transform with constrained values
target.style.transform = `translate(${finalX}px, ${finalY}px)`;
}
/**
* Calculates distances and coordinates between mouse and element center
* @param target - The target element
* @param mouseX - Mouse X coordinate
* @param mouseY - Mouse Y coordinate
* @returns Object containing delta values and distance
*/
private calculateCoordinates(target: HTMLElement, mouseX: number, mouseY: number): MagneticData {
const viewportOffset = target.getBoundingClientRect();
// Center point of target relative to viewport
const centerX = viewportOffset.left + target.offsetWidth / 2;
const centerY = viewportOffset.top + target.offsetHeight / 2;
// Calculate delta with attraction factor
const deltaX = (mouseX - centerX) * this.settings.attraction;
const deltaY = (mouseY - centerY) * this.settings.attraction;
// Calculate distance from bounding rect edges
const distanceX = Math.max(0, Math.abs(mouseX - centerX) - target.offsetWidth / 2);
const distanceY = Math.max(0, Math.abs(mouseY - centerY) - target.offsetHeight / 2);
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
return {deltaX, deltaY, distance};
}
/**
* Returns the total magnetized area dimensions
* @returns Object containing width and height of the magnetized area
*/
public getMagnetizedArea(): { width: number; height: number } {
if (!this.target) {
return {width: 0, height: 0};
}
return {
width: this.target.offsetWidth + this.settings.distance * 2,
height: this.target.offsetHeight + this.settings.distance * 2
};
}
/**
* Destroys the magnetic button instance and cleans up all event listeners
*/
public destroy(): void {
if (this.boundMagnetize) {
window.removeEventListener('mousemove', this.boundMagnetize);
this.boundMagnetize = null;
}
if (this.target) {
this.target.classList.remove('is-magnetized', this.settings.activeClass);
this.target.style.transform = '';
MagneticButton.initializedElements.delete(this.target);
this.target = null;
}
this.isEnter = false;
this.lerpPos = {x: 0, y: 0};
}
}
/**
* Auto-initialize magnetic buttons when DOM is ready (only in browser environment)
*/
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new MagneticButton());
} else {
new MagneticButton();
}
}
export default MagneticButton;