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

Avis

Hover Card 3D Tilt — React Hover-cards Section — Incubator