Retour au catalogue
Hover Card 3D Tilt
Cartes avec effet 3D tilt avance, reflet de lumiere dynamique, ombre portee reactive et animation de profondeur au survol.
hover-cardscomplex Both Responsive a11y
boldplayfulagencysaasportfoliogrid
Theme
"use client";
import { useRef, useState, useCallback } from "react";
import { motion } from "framer-motion";
import * as LucideIcons from "lucide-react";
import React from "react";
interface TiltCardData {
id: string;
icon?: string;
title: string;
description: string;
value?: string;
valueLabel?: string;
gradient?: string;
}
interface HoverCard3dTiltProps {
badge?: string;
title?: string;
subtitle?: string;
cards?: TiltCardData[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
function getIcon(name?: string): React.ElementType | null {
if (!name) return null;
return (LucideIcons as unknown as Record<string, React.ElementType>)[name] ?? null;
}
function Card3D({ card, index }: { card: TiltCardData; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const [tilt, setTilt] = useState({ rotateX: 0, rotateY: 0, glareX: 50, glareY: 50 });
const [isHovered, setIsHovered] = useState(false);
const Icon = getIcon(card.icon);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
const rotateX = ((y - cy) / cy) * -12;
const rotateY = ((x - cx) / cx) * 12;
const glareX = (x / rect.width) * 100;
const glareY = (y / rect.height) * 100;
setTilt({ rotateX, rotateY, glareX, glareY });
}, []);
const handleMouseLeave = useCallback(() => {
setTilt({ rotateX: 0, rotateY: 0, glareX: 50, glareY: 50 });
setIsHovered(false);
}, []);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 32, rotateX: 8 }}
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: index * 0.12, duration: 0.6, ease: EASE }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
style={{
perspective: "800px",
cursor: "default",
}}
>
<div
style={{
transform: `perspective(800px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg) scale(${isHovered ? 1.02 : 1})`,
transition: "transform 0.15s ease-out, box-shadow 0.2s ease-out",
borderRadius: "20px",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
padding: "2rem",
position: "relative",
overflow: "hidden",
willChange: "transform",
boxShadow: isHovered
? `0 20px 60px color-mix(in srgb, var(--color-foreground) 12%, transparent), 0 ${tilt.rotateX * -0.5}px ${Math.abs(tilt.rotateY) * 2 + 20}px color-mix(in srgb, var(--color-accent) 8%, transparent)`
: "0 4px 16px color-mix(in srgb, var(--color-foreground) 4%, transparent)",
}}
>
{/* Glare overlay */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
borderRadius: "inherit",
background: `radial-gradient(circle at ${tilt.glareX}% ${tilt.glareY}%, color-mix(in srgb, var(--color-accent) ${isHovered ? 12 : 0}%, transparent), transparent 60%)`,
pointerEvents: "none",
transition: "background 0.15s",
zIndex: 1,
}}
/>
{/* Depth shadow on hover */}
<div
aria-hidden
style={{
position: "absolute",
bottom: "-8px",
left: "10%",
right: "10%",
height: "24px",
borderRadius: "50%",
background: "color-mix(in srgb, var(--color-foreground) 6%, transparent)",
filter: "blur(12px)",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s",
zIndex: 0,
}}
/>
<div style={{ position: "relative", zIndex: 2 }}>
{Icon && (
<div
style={{
width: "56px",
height: "56px",
borderRadius: "16px",
background: card.gradient ?? "color-mix(in srgb, var(--color-accent) 12%, transparent)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "1.5rem",
transform: isHovered ? `translate(${tilt.rotateY * 0.3}px, ${tilt.rotateX * -0.3}px)` : "none",
transition: "transform 0.15s",
}}
>
<Icon style={{ width: 26, height: 26, color: "var(--color-accent)" }} />
</div>
)}
<h3
style={{
fontSize: "1.125rem",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.625rem",
letterSpacing: "-0.01em",
}}
>
{card.title}
</h3>
<p
style={{
fontSize: "0.875rem",
lineHeight: 1.6,
color: "var(--color-foreground-muted)",
marginBottom: card.value ? "1.5rem" : "0",
}}
>
{card.description}
</p>
{card.value && (
<div
style={{
paddingTop: "1rem",
borderTop: "1px solid var(--color-border)",
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
}}
>
<motion.span
animate={isHovered ? { scale: [1, 1.05, 1] } : {}}
transition={{ duration: 0.3 }}
style={{
fontSize: "1.75rem",
fontWeight: 800,
color: "var(--color-accent)",
lineHeight: 1,
}}
>
{card.value}
</motion.span>
{card.valueLabel && (
<span style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)" }}>
{card.valueLabel}
</span>
)}
</div>
)}
</div>
</div>
</motion.div>
);
}
export default function HoverCard3dTilt({
badge,
title,
subtitle,
cards,
}: HoverCard3dTiltProps) {
const resolvedBadge = badge ?? "Performance";
const resolvedTitle = title ?? "Une experience immersive";
const resolvedSubtitle = subtitle ?? "Survolez les cartes pour decouvrir l'effet 3D avec reflets et ombres reactives.";
const resolvedCards: TiltCardData[] = cards ?? [
{ id: "t1", icon: "Zap", title: "Ultra rapide", description: "Temps de reponse inferieur a 50ms grace a notre infrastructure mondiale.", value: "<50ms", valueLabel: "latence" },
{ id: "t2", icon: "Shield", title: "Securise", description: "Chiffrement de bout en bout et conformite RGPD integree.", value: "99.9%", valueLabel: "uptime" },
{ id: "t3", icon: "Users", title: "Collaboratif", description: "Travaillez en equipe en temps reel avec gestion granulaire des droits.", value: "50k+", valueLabel: "equipes" },
{ id: "t4", icon: "Layers", title: "Extensible", description: "Architecture modulaire avec plus de 200 plugins disponibles.", value: "200+", valueLabel: "plugins" },
];
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(260px, 1fr))",
}}
>
{resolvedCards.map((card, i) => (
<Card3D key={card.id} card={card} index={i} />
))}
</div>
</div>
</section>
);
}