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