effects#interactive#hover
Magnetic Cursor
Interactive elements that subtly deform and pull toward the cursor as it approaches
Implementation Guide
# Magnetic Cursor Effect
> Interactive elements that subtly deform and pull toward the cursor as it approaches. The element "reaches out" to be clicked - a satisfying micro-interaction.
## Quick Start
```tsx
import MagneticCursor from '@/components/effects/magnetic-cursor';
export default function Hero() {
return (
<div className="relative h-screen">
<MagneticCursor strength={0.3} radius={150} />
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| -------- | ------ | ------- | ---------------------------------------------- |
| strength | number | 0.3 | Magnetic pull strength (0.1 - 1.0 recommended) |
| radius | number | 150 | Activation radius in pixels |
| scale | number | 1.1 | Maximum scale on hover (1.0 = no scaling) |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface MagneticCursorProps {
strength?: number;
radius?: number;
scale?: number;
}
interface MagneticElementState {
translateX: number;
translateY: number;
scale: number;
rotate: number;
}
interface MagneticItemProps {
children: React.ReactNode;
strength: number;
radius: number;
maxScale: number;
reducedMotion: boolean;
className?: string;
}
const DEFAULT_STATE: MagneticElementState = {
translateX: 0,
translateY: 0,
scale: 1,
rotate: 0,
};
function lerp(start: number, end: number, factor: number): number {
return start + (end - start) * factor;
}
function MagneticItem({
children,
strength,
radius,
maxScale,
reducedMotion,
className = '',
}: MagneticItemProps) {
const elementRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<MagneticElementState>(DEFAULT_STATE);
const animationFrameRef = useRef<number>(0);
const targetStateRef = useRef<MagneticElementState>({ ...DEFAULT_STATE });
const currentStateRef = useRef<MagneticElementState>({ ...DEFAULT_STATE });
useEffect(() => {
if (reducedMotion) return;
const element = elementRef.current;
if (!element) return;
const animateToTarget = () => {
const springFactor = 0.15;
const current = currentStateRef.current;
const target = targetStateRef.current;
const newState = {
translateX: lerp(current.translateX, target.translateX, springFactor),
translateY: lerp(current.translateY, target.translateY, springFactor),
scale: lerp(current.scale, target.scale, springFactor),
rotate: lerp(current.rotate, target.rotate, springFactor),
};
currentStateRef.current = newState;
const hasSignificantChange =
Math.abs(newState.translateX - target.translateX) > 0.01 ||
Math.abs(newState.translateY - target.translateY) > 0.01 ||
Math.abs(newState.scale - target.scale) > 0.001 ||
Math.abs(newState.rotate - target.rotate) > 0.01;
setState(newState);
if (hasSignificantChange) {
animationFrameRef.current = requestAnimationFrame(animateToTarget);
}
};
const handleMouseMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
const elementCenterX = rect.left + rect.width / 2;
const elementCenterY = rect.top + rect.height / 2;
const distanceX = event.clientX - elementCenterX;
const distanceY = event.clientY - elementCenterY;
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
if (distance < radius) {
// Calculate eased distance for smooth falloff
const normalizedDistance = 1 - distance / radius;
const easeDistance = normalizedDistance * normalizedDistance;
// Calculate magnetic pull
const magneticPullX = distanceX * strength * easeDistance;
const magneticPullY = distanceY * strength * easeDistance;
// Calculate subtle rotation based on cursor angle
const angle = Math.atan2(distanceY, distanceX);
const rotateAmount = ((angle * 180) / Math.PI) * 0.05 * easeDistance;
// Calculate scale based on proximity
const scaleAmount = 1 + (maxScale - 1) * easeDistance;
targetStateRef.current = {
translateX: magneticPullX,
translateY: magneticPullY,
scale: scaleAmount,
rotate: rotateAmount,
};
} else {
// Reset when cursor is outside radius
targetStateRef.current = { ...DEFAULT_STATE };
}
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = requestAnimationFrame(animateToTarget);
};
const handleMouseLeave = () => {
targetStateRef.current = { ...DEFAULT_STATE };
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = requestAnimationFrame(animateToTarget);
};
window.addEventListener('mousemove', handleMouseMove);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
element.removeEventListener('mouseleave', handleMouseLeave);
cancelAnimationFrame(animationFrameRef.current);
};
}, [strength, radius, maxScale, reducedMotion]);
return (
<div
ref={elementRef}
className={className}
style={{
transform: `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale}) rotate(${state.rotate}deg)`,
willChange: 'transform',
}}
>
{children}
</div>
);
}
export default function MagneticCursor({
strength = 0.3,
radius = 150,
scale = 1.1,
}: MagneticCursorProps) {
const prefersReducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return (
<div className="flex h-full w-full items-center justify-center gap-4">
<MagneticItem
strength={strength}
radius={radius}
maxScale={scale}
reducedMotion={prefersReducedMotion}
>
<button className="bg-blue-500 px-6 py-3 text-white">Hover Me</button>
</MagneticItem>
</div>
);
}
```
## Customization
### Using MagneticItem Wrapper
Export and use the `MagneticItem` component to wrap any element:
```tsx
<MagneticItem strength={0.3} radius={150} maxScale={1.1} reducedMotion={false}>
<button className="your-button-styles">Click Me</button>
</MagneticItem>
```
### Subtle Effect for Buttons
For a more subtle, professional feel on navigation buttons:
```tsx
<MagneticItem strength={0.2} radius={100} maxScale={1.05} reducedMotion={false}>
<button>Navigate</button>
</MagneticItem>
```
### Strong Effect for CTAs
For attention-grabbing call-to-action buttons:
```tsx
<MagneticItem strength={0.5} radius={200} maxScale={1.15} reducedMotion={false}>
<button>Get Started</button>
</MagneticItem>
```
### Cards and Larger Elements
For cards, reduce strength and increase radius for a gentler effect:
```tsx
<MagneticItem strength={0.15} radius={180} maxScale={1.03} reducedMotion={false}>
<div className="card">
<h3>Card Title</h3>
<p>Card content here</p>
</div>
</MagneticItem>
```
### Social Icons
For small icons, increase strength with smaller radius:
```tsx
<MagneticItem strength={0.45} radius={80} maxScale={1.2} reducedMotion={false}>
<a href="#" className="social-icon">
<TwitterIcon />
</a>
</MagneticItem>
```
## Performance Considerations
- **requestAnimationFrame**: The effect uses `requestAnimationFrame` for smooth 60fps animations
- **Spring Physics**: Linear interpolation (lerp) with a spring factor creates natural easing without heavy physics calculations
- **Automatic Cleanup**: Animation frames are cancelled when cursor leaves the activation radius
- **Refs for State**: Target and current state are stored in refs to avoid unnecessary re-renders during animation
- **will-change**: The `will-change: transform` CSS property hints to the browser for GPU acceleration
- **Early Exit**: The effect stops animating when the difference is imperceptible (< 0.01 pixels)
### Optimization Tips
- Limit the number of magnetic elements on a page (5-10 recommended)
- Use larger `radius` values to create overlap and reduce the number of active calculations
- Consider disabling the effect on mobile devices where hover is not applicable
- Avoid placing magnetic elements in scrolling containers
## Accessibility
- **Reduced Motion**: Fully respects `prefers-reduced-motion` media query - all transforms are disabled
- **No Content Shifting**: The magnetic effect only moves the element visually, not the underlying DOM position
- **Keyboard Navigation**: Elements remain fully keyboard accessible - the effect is purely visual
- **Focus States**: Ensure your wrapped elements have proper focus styles for keyboard users
- **Screen Readers**: The effect is purely decorative and doesn't affect screen reader announcements
### Accessibility Best Practices
```tsx
// Always ensure wrapped elements have focus styles
<MagneticItem {...props}>
<button className="focus:ring-2 focus:ring-blue-500 focus:outline-none">Accessible Button</button>
</MagneticItem>
```
## Browser Support
- Modern browsers with ES6+ support
- `requestAnimationFrame` support required (all modern browsers)
- CSS `transform` and `will-change` support required
- `matchMedia` for reduced motion detection
### Known Limitations
- Touch devices: The hover effect won't work on touch-only devices. Consider showing a tap feedback effect instead.
- Pointer coarse: Devices with coarse pointers (like styluses) may not trigger the effect as expected
- High refresh rate monitors: The effect will automatically take advantage of higher refresh rates
### Mobile Fallback
```tsx
const isTouchDevice = 'ontouchstart' in window;
<MagneticItem
strength={isTouchDevice ? 0 : 0.3}
radius={isTouchDevice ? 0 : 150}
maxScale={isTouchDevice ? 1 : 1.1}
reducedMotion={isTouchDevice || reducedMotion}
>
<button>Works on All Devices</button>
</MagneticItem>;
```