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>
);
}