---
name: cursor-effects
description: Curseur custom avec dot et outline mix-blend-difference, agrandissement hover sur liens et boutons, hook useMagnetic pour CTA
---

# Cursor Effects

Curseur custom premium avec dot intérieur et outline extérieur en mix-blend-difference, agrandissement au hover sur liens et boutons, et hook useMagnetic() pour les CTA.

## 1. Composant Cursor Custom

```tsx
"use client";

import { useRef, useEffect, useState, useCallback } from "react";
import { gsap } from "gsap";

export function CustomCursor() {
  const dotRef = useRef<HTMLDivElement>(null);
  const outlineRef = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);
  const [isHovering, setIsHovering] = useState(false);
  const [isClicking, setIsClicking] = useState(false);
  const [cursorText, setCursorText] = useState("");

  const mousePos = useRef({ x: 0, y: 0 });

  useEffect(() => {
    // Ne pas afficher sur mobile/touch
    const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
    if (isTouchDevice) return;

    const handleMouseMove = (e: MouseEvent) => {
      mousePos.current = { x: e.clientX, y: e.clientY };

      if (!isVisible) setIsVisible(true);

      // Le dot suit immédiatement
      gsap.to(dotRef.current, {
        x: e.clientX,
        y: e.clientY,
        duration: 0.1,
        ease: "power2.out",
      });

      // L'outline suit avec un léger retard
      gsap.to(outlineRef.current, {
        x: e.clientX,
        y: e.clientY,
        duration: 0.5,
        ease: "power3.out",
      });
    };

    const handleMouseDown = () => setIsClicking(true);
    const handleMouseUp = () => setIsClicking(false);
    const handleMouseLeave = () => setIsVisible(false);
    const handleMouseEnter = () => setIsVisible(true);

    // Détecter les éléments interactifs
    const handleElementHover = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      const interactiveEl = target.closest("a, button, [data-cursor], input, textarea, select");

      if (interactiveEl) {
        setIsHovering(true);
        const cursorAttr = interactiveEl.getAttribute("data-cursor");
        if (cursorAttr) setCursorText(cursorAttr);
      } else {
        setIsHovering(false);
        setCursorText("");
      }
    };

    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mousemove", handleElementHover);
    window.addEventListener("mousedown", handleMouseDown);
    window.addEventListener("mouseup", handleMouseUp);
    document.documentElement.addEventListener("mouseleave", handleMouseLeave);
    document.documentElement.addEventListener("mouseenter", handleMouseEnter);

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mousemove", handleElementHover);
      window.removeEventListener("mousedown", handleMouseDown);
      window.removeEventListener("mouseup", handleMouseUp);
      document.documentElement.removeEventListener("mouseleave", handleMouseLeave);
      document.documentElement.removeEventListener("mouseenter", handleMouseEnter);
    };
  }, [isVisible]);

  // Animations d'état
  useEffect(() => {
    if (isHovering) {
      gsap.to(outlineRef.current, {
        width: cursorText ? 120 : 60,
        height: cursorText ? 120 : 60,
        opacity: cursorText ? 0.9 : 0.5,
        duration: 0.4,
        ease: "expo.out",
      });
      gsap.to(dotRef.current, {
        scale: 0,
        duration: 0.3,
        ease: "expo.out",
      });
    } else {
      gsap.to(outlineRef.current, {
        width: 40,
        height: 40,
        opacity: 1,
        duration: 0.4,
        ease: "expo.out",
      });
      gsap.to(dotRef.current, {
        scale: 1,
        duration: 0.3,
        ease: "expo.out",
      });
    }
  }, [isHovering, cursorText]);

  useEffect(() => {
    gsap.to(dotRef.current, {
      scale: isClicking ? 0.5 : isHovering ? 0 : 1,
      duration: 0.2,
      ease: "expo.out",
    });
  }, [isClicking, isHovering]);

  // Cacher sur mobile
  if (typeof window !== "undefined" && window.innerWidth < 1024) return null;

  return (
    <>
      {/* Cacher le curseur natif */}
      <style jsx global>{`
        @media (min-width: 1024px) {
          * {
            cursor: none !important;
          }
        }
      `}</style>

      {/* Dot central */}
      <div
        ref={dotRef}
        className="fixed top-0 left-0 z-[9999] pointer-events-none"
        style={{
          opacity: isVisible ? 1 : 0,
          transform: "translate(-50%, -50%)",
          transition: "opacity 0.3s",
        }}
      >
        <div className="w-2 h-2 rounded-full bg-white mix-blend-difference" />
      </div>

      {/* Outline */}
      <div
        ref={outlineRef}
        className="fixed top-0 left-0 z-[9998] pointer-events-none flex items-center justify-center"
        style={{
          width: 40,
          height: 40,
          opacity: isVisible ? 1 : 0,
          transform: "translate(-50%, -50%)",
          transition: "opacity 0.3s",
        }}
      >
        <div className="w-full h-full rounded-full border border-white mix-blend-difference" />

        {/* Texte du curseur */}
        {cursorText && (
          <span className="absolute text-xs font-medium text-white mix-blend-difference uppercase tracking-wider">
            {cursorText}
          </span>
        )}
      </div>
    </>
  );
}
```

### Utilisation

```tsx
// Dans le layout.tsx
import { CustomCursor } from "@/components/shared/CustomCursor";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="fr">
      <body>
        <CustomCursor />
        {children}
      </body>
    </html>
  );
}

// Attribut data-cursor pour texte custom
<a href="/projets/xyz" data-cursor="Voir">
  <img src="/project.jpg" alt="Projet" />
</a>

<button data-cursor="Play">
  <video ... />
</button>
```

## 2. Hook useMagnetic()

Attire l'élément vers le curseur quand celui-ci est proche. Parfait pour les CTA et boutons importants.

```tsx
"use client";

import { useRef, useEffect, useCallback } from "react";
import { gsap } from "gsap";

interface MagneticOptions {
  /** Force de l'attraction (0-1, défaut 0.3) */
  strength?: number;
  /** Distance d'activation en px (défaut 100) */
  distance?: number;
  /** Durée du retour en secondes */
  returnDuration?: number;
}

export function useMagnetic(options: MagneticOptions = {}) {
  const { strength = 0.3, distance = 100, returnDuration = 0.6 } = options;
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // Ne pas activer sur mobile
    const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
    if (isTouchDevice) return;

    const handleMouseMove = (e: MouseEvent) => {
      const rect = el.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;

      const deltaX = e.clientX - centerX;
      const deltaY = e.clientY - centerY;
      const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

      if (dist < distance) {
        const factor = 1 - dist / distance; // Plus on est proche, plus c'est fort
        gsap.to(el, {
          x: deltaX * strength * factor,
          y: deltaY * strength * factor,
          duration: 0.3,
          ease: "power2.out",
        });
      } else {
        gsap.to(el, {
          x: 0,
          y: 0,
          duration: returnDuration,
          ease: "elastic.out(1, 0.3)",
        });
      }
    };

    const handleMouseLeave = () => {
      gsap.to(el, {
        x: 0,
        y: 0,
        duration: returnDuration,
        ease: "elastic.out(1, 0.3)",
      });
    };

    window.addEventListener("mousemove", handleMouseMove);
    el.addEventListener("mouseleave", handleMouseLeave);

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      el.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, [strength, distance, returnDuration]);

  return ref;
}
```

### Utilisation du useMagnetic

```tsx
"use client";

import { useMagnetic } from "@/hooks/useMagnetic";
import { Button } from "@/components/ui/Button";

export function MagneticButton({ children, ...props }: React.ComponentProps<typeof Button>) {
  const magneticRef = useMagnetic({ strength: 0.4, distance: 120 });

  return (
    <div ref={magneticRef as React.RefObject<HTMLDivElement>} className="inline-block">
      <Button {...props}>{children}</Button>
    </div>
  );
}

// Utilisation
<MagneticButton size="lg">
  Nous contacter
</MagneticButton>
```

## 3. Composant MagneticLink

Pour les liens de navigation :

```tsx
"use client";

import { useMagnetic } from "@/hooks/useMagnetic";
import { cn } from "@/lib/utils";

interface MagneticLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  children: React.ReactNode;
  magneticStrength?: number;
}

export function MagneticLink({
  children,
  className,
  magneticStrength = 0.2,
  ...props
}: MagneticLinkProps) {
  const ref = useMagnetic({ strength: magneticStrength, distance: 80 });

  return (
    <a
      ref={ref as React.RefObject<HTMLAnchorElement>}
      className={cn("inline-block", className)}
      {...props}
    >
      {children}
    </a>
  );
}
```

## 4. Cursor avec Stick Effect (pour icônes/ronds)

```tsx
"use client";

import { useRef, useEffect } from "react";
import { gsap } from "gsap";

/**
 * Fait coller le curseur custom au centre de l'élément au hover
 */
export function useStickyCursor() {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const handleMouseEnter = () => {
      const rect = el.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;

      // Dispatche un événement custom pour que le curseur sache où coller
      window.dispatchEvent(
        new CustomEvent("cursor-stick", {
          detail: { x: centerX, y: centerY, stuck: true },
        })
      );
    };

    const handleMouseLeave = () => {
      window.dispatchEvent(
        new CustomEvent("cursor-stick", {
          detail: { stuck: false },
        })
      );
    };

    el.addEventListener("mouseenter", handleMouseEnter);
    el.addEventListener("mouseleave", handleMouseLeave);

    return () => {
      el.removeEventListener("mouseenter", handleMouseEnter);
      el.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, []);

  return ref;
}
```

## Notes

```markdown
- Le curseur custom est désactivé sur mobile (< 1024px) et appareils tactiles
- mix-blend-difference inverse les couleurs : blanc sur fond sombre, noir sur fond clair
- data-cursor="Texte" permet d'afficher un texte dans le curseur au hover
- Le magnetic effect utilise un elastic easing pour le retour, ce qui donne un effet organique
- Performance : GSAP optimise automatiquement avec requestAnimationFrame
- Attention au z-index : le curseur doit être au-dessus de tout (z-[9999])
```

## Checklist

- [ ] CustomCursor avec dot + outline mix-blend-difference
- [ ] Agrandissement au hover sur liens et boutons
- [ ] Texte dans le curseur via data-cursor=""
- [ ] Hook useMagnetic() avec strength et distance configurables
- [ ] MagneticButton composant prêt à l'emploi
- [ ] MagneticLink pour la navigation
- [ ] Désactivé sur mobile et touch devices
- [ ] Cursor natif caché via CSS
- [ ] Animation click (dot scale down)
- [ ] Smooth follow avec GSAP (dot rapide, outline lent)
