Retour au catalogue

Hover Card Magnetic

Cartes avec effet d'attraction magnetique — le contenu se deplace subtilement vers le curseur avec elasticite et rebond.

hover-cardsmedium Both Responsive a11y
playfulboldagencyportfoliosaasgrid
Theme
"use client";

import { useRef, useState, useCallback } from "react";
import { motion, useSpring, useMotionValue } from "framer-motion";
import * as LucideIcons from "lucide-react";
import { ArrowUpRight } from "lucide-react";
import React from "react";

interface MagneticCardData {
  id: string;
  icon?: string;
  title: string;
  description: string;
  href?: string;
  color?: string;
}

interface HoverCardMagneticProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  cards?: MagneticCardData[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const SPRING = { stiffness: 150, damping: 15, mass: 0.5 };

function getIcon(name?: string): React.ElementType | null {
  if (!name) return null;
  return (LucideIcons as unknown as Record<string, React.ElementType>)[name] ?? null;
}

function MagneticCard({ card, index }: { card: MagneticCardData; index: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const [isHovered, setIsHovered] = useState(false);
  const Icon = getIcon(card.icon);

  const magnetX = useMotionValue(0);
  const magnetY = useMotionValue(0);
  const springX = useSpring(magnetX, SPRING);
  const springY = useSpring(magnetY, SPRING);

  const iconX = useMotionValue(0);
  const iconY = useMotionValue(0);
  const iconSpringX = useSpring(iconX, { stiffness: 200, damping: 12, mass: 0.3 });
  const iconSpringY = useSpring(iconY, { stiffness: 200, damping: 12, mass: 0.3 });

  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const x = e.clientX - rect.left - rect.width / 2;
    const y = e.clientY - rect.top - rect.height / 2;
    magnetX.set(x * 0.08);
    magnetY.set(y * 0.08);
    iconX.set(x * 0.15);
    iconY.set(y * 0.15);
  }, [magnetX, magnetY, iconX, iconY]);

  const handleMouseLeave = useCallback(() => {
    magnetX.set(0);
    magnetY.set(0);
    iconX.set(0);
    iconY.set(0);
    setIsHovered(false);
  }, [magnetX, magnetY, iconX, iconY]);

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 24 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-40px" }}
      transition={{ delay: index * 0.1, duration: 0.5, ease: EASE }}
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={handleMouseLeave}
      style={{
        cursor: card.href ? "pointer" : "default",
      }}
    >
      <motion.div
        style={{
          x: springX,
          y: springY,
          borderRadius: "20px",
          border: "1px solid var(--color-border)",
          background: "var(--color-background-card)",
          padding: "2rem",
          position: "relative",
          overflow: "hidden",
          height: "100%",
          transition: "border-color 0.3s, box-shadow 0.3s",
          borderColor: isHovered ? "color-mix(in srgb, var(--color-accent) 30%, var(--color-border))" : "var(--color-border)",
          boxShadow: isHovered
            ? "0 16px 48px color-mix(in srgb, var(--color-foreground) 8%, transparent)"
            : "none",
        }}
      >
        <div style={{ position: "relative", zIndex: 1 }}>
          <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.25rem" }}>
            {Icon && (
              <motion.div
                style={{
                  x: iconSpringX,
                  y: iconSpringY,
                  width: "52px",
                  height: "52px",
                  borderRadius: "14px",
                  background: card.color ?? "color-mix(in srgb, var(--color-accent) 12%, transparent)",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                }}
              >
                <Icon style={{ width: 24, height: 24, color: "var(--color-accent)" }} />
              </motion.div>
            )}

            {card.href && (
              <motion.div
                animate={{ opacity: isHovered ? 1 : 0, scale: isHovered ? 1 : 0.8 }}
                transition={{ duration: 0.2 }}
                style={{
                  width: "32px",
                  height: "32px",
                  borderRadius: "50%",
                  background: "var(--color-accent)",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                }}
              >
                <ArrowUpRight style={{ width: 16, height: 16, color: "var(--color-background)" }} />
              </motion.div>
            )}
          </div>

          <motion.h3
            style={{
              x: iconSpringX,
              fontSize: "1.125rem",
              fontWeight: 700,
              color: "var(--color-foreground)",
              marginBottom: "0.5rem",
            }}
          >
            {card.title}
          </motion.h3>

          <motion.p
            style={{
              fontSize: "0.875rem",
              lineHeight: 1.6,
              color: "var(--color-foreground-muted)",
              margin: 0,
            }}
          >
            {card.description}
          </motion.p>
        </div>

        {/* Magnetic field visualization */}
        <motion.div
          aria-hidden
          animate={{ opacity: isHovered ? 0.4 : 0 }}
          transition={{ duration: 0.3 }}
          style={{
            position: "absolute",
            bottom: "-40px",
            right: "-40px",
            width: "160px",
            height: "160px",
            borderRadius: "50%",
            background: "radial-gradient(circle, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 70%)",
            pointerEvents: "none",
          }}
        />
      </motion.div>
    </motion.div>
  );
}

export default function HoverCardMagnetic({
  badge,
  title,
  subtitle,
  cards,
}: HoverCardMagneticProps) {
  const resolvedBadge = badge ?? "Decouvrir";
  const resolvedTitle = title ?? "Attirez votre attention";
  const resolvedSubtitle = subtitle ?? "Nos cartes magnetiques reagissent a vos mouvements avec un effet d'attraction physique realiste.";

  const resolvedCards: MagneticCardData[] = cards ?? [
    { id: "m1", icon: "Palette", title: "Design systeme", description: "Un systeme de design unifie avec tokens, composants et guidelines pour une coherence totale.", href: "#" },
    { id: "m2", icon: "Code2", title: "API premiere", description: "Architecture API-first avec documentation OpenAPI generee automatiquement.", href: "#" },
    { id: "m3", icon: "GitBranch", title: "Versionnage avance", description: "Branching, merging et historique complet avec resolution de conflits intelligente." },
    { id: "m4", icon: "Rocket", title: "Deploiement continu", description: "Pipeline CI/CD integre avec preview deployments et rollback automatique.", href: "#" },
    { id: "m5", icon: "LineChart", title: "Metriques temps reel", description: "Suivi des performances, erreurs et usage avec alertes configurables." },
    { id: "m6", icon: "MessageSquare", title: "Collaboration", description: "Commentaires, mentions et notifications en temps reel pour votre equipe.", href: "#" },
  ];

  return (
    <section
      style={{
        padding: "var(--section-padding-y, 6rem) 0",
        background: "var(--color-background-alt)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width, 1200px)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x, 1.5rem)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{ textAlign: "center", maxWidth: "600px", margin: "0 auto 3.5rem" }}
        >
          <span style={{ display: "inline-block", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-accent)", marginBottom: "0.75rem" }}>
            {resolvedBadge}
          </span>
          <h2 style={{ fontSize: "clamp(1.75rem, 3vw, 2.75rem)", fontWeight: 700, lineHeight: 1.15, letterSpacing: "-0.02em", color: "var(--color-foreground)", marginBottom: "1rem" }}>
            {resolvedTitle}
          </h2>
          <p style={{ fontSize: "1rem", lineHeight: 1.7, color: "var(--color-foreground-muted)" }}>
            {resolvedSubtitle}
          </p>
        </motion.div>

        <div
          style={{
            display: "grid",
            gap: "1.5rem",
            gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
          }}
        >
          {resolvedCards.map((card, i) => (
            <MagneticCard key={card.id} card={card} index={i} />
          ))}
        </div>
      </div>
    </section>
  );
}

Avis

Hover Card Magnetic — React Hover-cards Section — Incubator