Retour au catalogue

Hero 3D Tilt

Hero avec carte 3D qui s'incline au survol de la souris. Effet perspective immersif.

heromedium Both Responsive a11y
boldplayfulsaasagencycentered
Theme
"use client";

import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { useRef } from "react";

interface Hero3dProps {
  title?: string;
  titleAccent?: string;
  description?: string;
  ctaLabel?: string;
  ctaUrl?: string;
  badge?: string;
}

const EASE = [0.16, 1, 0.3, 1] as const;

export default function Hero3d({
  title = "Votre titre principal",
  titleAccent = "innovant",
  description = "Description du hero",
  ctaLabel = "Commencer",
  ctaUrl = "#contact",
  badge = "",
}: Hero3dProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const mouseX = useMotionValue(0);
  const mouseY = useMotionValue(0);

  const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [8, -8]), {
    stiffness: 200,
    damping: 30,
  });
  const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-8, 8]), {
    stiffness: 200,
    damping: 30,
  });

  function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
    const rect = cardRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = (e.clientX - rect.left) / rect.width - 0.5;
    const y = (e.clientY - rect.top) / rect.height - 0.5;
    mouseX.set(x);
    mouseY.set(y);
  }

  function handleMouseLeave() {
    mouseX.set(0);
    mouseY.set(0);
  }

  return (
    <section
      className="relative overflow-hidden flex items-center justify-center"
      style={{
        paddingTop: "var(--section-padding-y-lg)",
        paddingBottom: "var(--section-padding-y-lg)",
        background: "var(--color-background)",
        minHeight: "85vh",
        perspective: "1200px",
      }}
    >
      <div
        className="mx-auto relative z-10"
        style={{
          maxWidth: "var(--container-max-width)",
          paddingLeft: "var(--container-padding-x)",
          paddingRight: "var(--container-padding-x)",
          width: "100%",
        }}
      >
        <motion.div
          ref={cardRef}
          onMouseMove={handleMouseMove}
          onMouseLeave={handleMouseLeave}
          style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
          className="relative rounded-2xl p-12 md:p-16 text-center"
        >
          {/* Glass background */}
          <div
            aria-hidden
            className="absolute inset-0 rounded-2xl"
            style={{
              background: "var(--color-background-alt)",
              border: "1px solid var(--color-border)",
              borderRadius: "var(--radius-xl)",
            }}
          />

          {/* Accent glow */}
          <div
            aria-hidden
            className="absolute -top-20 left-1/2 -translate-x-1/2 w-80 h-80 rounded-full pointer-events-none"
            style={{
              background: "var(--color-accent)",
              opacity: 0.08,
              filter: "blur(80px)",
            }}
          />

          <div className="relative z-10">
            {badge && (
              <motion.span
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.4, ease: EASE }}
                className="inline-block px-4 py-1.5 rounded-full text-xs font-medium mb-6"
                style={{
                  border: "1px solid var(--color-accent-border)",
                  background: "var(--color-accent-subtle)",
                  color: "var(--color-foreground-muted)",
                }}
              >
                {badge}
              </motion.span>
            )}

            <motion.h1
              initial={{ opacity: 0, y: 20 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.6, delay: 0.06, ease: EASE }}
              className="font-bold tracking-tight mb-6"
              style={{
                fontFamily: "var(--font-sans)",
                fontSize: "clamp(2.25rem, 4.5vw, 4rem)",
                lineHeight: 1.1,
                color: "var(--color-foreground)",
              }}
            >
              {title}{" "}
              <em
                style={{
                  fontFamily: "var(--font-serif)",
                  fontStyle: "italic",
                  fontWeight: 400,
                  color: "var(--color-accent)",
                }}
              >
                {titleAccent}
              </em>
            </motion.h1>

            <motion.p
              initial={{ opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.5, delay: 0.14, ease: EASE }}
              className="text-base leading-relaxed max-w-md mx-auto mb-8"
              style={{ color: "var(--color-foreground-muted)" }}
            >
              {description}
            </motion.p>

            <motion.a
              href={ctaUrl}
              initial={{ opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.45, delay: 0.22, ease: EASE }}
              className="inline-flex items-center gap-2 px-7 py-3 rounded-full text-sm font-semibold"
              style={{
                background: "var(--color-accent)",
                color: "var(--color-foreground)",
                textDecoration: "none",
              }}
            >
              {ctaLabel}
              <ArrowRight className="h-4 w-4" />
            </motion.a>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Avis

Hero 3D Tilt — React Hero Section — Incubator