Retour au catalogue

Hero 3D Marquee

Hero classique avec titre et CTA, surmonté d'un tapis de marquee en perspective 3D (rotateX 18°). Deux rows défilant en sens opposés créent un effet 'tapis roulant' vu de côté — moderne et mémorable.

heromedium Both Responsive a11y
boldminimalcorporateagencysaasportfoliocentered
Theme
"use client";

import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";

interface Hero3dMarqueeProps {
  title?: string;
  titleAccent?: string;
  description?: string;
  ctaLabel?: string;
  ctaUrl?: string;
  ctaSecondaryLabel?: string;
  ctaSecondaryUrl?: string;
  marqueeItems?: string[];
  badge?: string;
}

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

const SEPARATOR = "·";

function MarqueeTrack({
  items,
  direction,
  duration,
  offsetY = 0,
  dimmed = false,
}: {
  items: string[];
  direction: 1 | -1;
  duration: number;
  offsetY?: number;
  dimmed?: boolean;
}) {
  const doubled = [...items, ...items];
  return (
    <div
      aria-hidden
      style={{
        overflow: "hidden",
        whiteSpace: "nowrap",
        transform: `translateY(${offsetY}px)`,
        marginBottom: "0.5rem",
      }}
    >
      <motion.div
        animate={{ x: direction === 1 ? ["0%", "-50%"] : ["-50%", "0%"] }}
        transition={{ duration, ease: "linear", repeat: Infinity }}
        style={{ display: "inline-flex", gap: "0 2.5rem", alignItems: "center" }}
      >
        {doubled.map((item, i) => (
          <span
            key={i}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: "1.5rem",
              fontSize: "clamp(1rem, 1.8vw, 1.375rem)",
              fontWeight: i % 3 === 0 ? 700 : 400,
              fontFamily: i % 3 === 0 ? "inherit" : "var(--font-serif)",
              fontStyle: i % 3 === 0 ? "normal" : "italic",
              color: dimmed
                ? "var(--color-foreground-muted)"
                : "var(--color-foreground)",
              letterSpacing: i % 3 === 0 ? "-0.02em" : "0",
              opacity: dimmed ? 0.6 : 1,
              whiteSpace: "nowrap",
            }}
          >
            {item}
            <span style={{ color: "var(--color-accent)", fontSize: "0.5em", opacity: 0.7 }}>
              {SEPARATOR}
            </span>
          </span>
        ))}
      </motion.div>
    </div>
  );
}

export default function Hero3dMarquee({
  title = "Votre prochain projet",
  titleAccent = "exceptionnel",
  description = "Nous créons des expériences digitales qui marquent les esprits. Design, développement, performance.",
  ctaLabel = "Démarrer un projet",
  ctaUrl = "#contact",
  ctaSecondaryLabel = "Voir nos réalisations",
  ctaSecondaryUrl = "#portfolio",
  marqueeItems = [],
  badge = "Disponible maintenant",
}: Hero3dMarqueeProps) {
  return (
    <section
      style={{
        position: "relative",
        overflow: "hidden",
        minHeight: "100vh",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        background: "var(--color-background)",
      }}
    >
      {/* ── Hero content ── */}
      <div
        style={{
          position: "relative",
          zIndex: 2,
          width: "100%",
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          textAlign: "center",
          paddingBottom: "clamp(5rem, 12vw, 9rem)",
        }}
      >
        {/* Badge */}
        <motion.div
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: "0.5rem",
            padding: "0.375rem 1rem",
            borderRadius: "var(--radius-full)",
            border: "1px solid var(--color-border)",
            background: "var(--color-background-card)",
            marginBottom: "2rem",
          }}
        >
          <span
            style={{
              width: 6,
              height: 6,
              borderRadius: "50%",
              background: "var(--color-accent)",
              display: "inline-block",
            }}
          />
          <span
            style={{
              fontSize: "0.75rem",
              letterSpacing: "0.08em",
              textTransform: "uppercase",
              fontWeight: 600,
              color: "var(--color-foreground-muted)",
            }}
          >
            {badge}
          </span>
        </motion.div>

        {/* Title */}
        <motion.h1
          initial={{ opacity: 0, y: 28 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.08, ease: EASE }}
          style={{
            fontSize: "clamp(2.75rem, 5.5vw, 5.25rem)",
            fontWeight: 800,
            lineHeight: 1.04,
            letterSpacing: "-0.04em",
            color: "var(--color-foreground)",
            marginBottom: "1.5rem",
            maxWidth: "820px",
            margin: "0 auto 1.5rem",
          }}
        >
          {title}{" "}
          <em
            style={{
              fontStyle: "italic",
              fontFamily: "var(--font-serif)",
              fontWeight: 400,
              color: "var(--color-accent)",
            }}
          >
            {titleAccent}
          </em>
        </motion.h1>

        {/* Description */}
        <motion.p
          initial={{ opacity: 0, y: 16 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.55, delay: 0.18, ease: EASE }}
          style={{
            fontSize: "1.125rem",
            lineHeight: 1.7,
            color: "var(--color-foreground-muted)",
            maxWidth: "520px",
            margin: "0 auto 2.5rem",
          }}
        >
          {description}
        </motion.p>

        {/* CTAs */}
        <motion.div
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.45, delay: 0.28, ease: EASE }}
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            gap: "1rem",
            flexWrap: "wrap",
          }}
        >
          <a
            href={ctaUrl}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: "8px",
              padding: "0.875rem 2rem",
              borderRadius: "var(--radius-full)",
              background: "var(--color-foreground)",
              color: "var(--color-background)",
              fontWeight: 600,
              fontSize: "0.9375rem",
              textDecoration: "none",
            }}
          >
            {ctaLabel}
            <ArrowRight style={{ width: 16, height: 16 }} />
          </a>
          <a
            href={ctaSecondaryUrl}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: "6px",
              padding: "0.875rem 1.75rem",
              borderRadius: "var(--radius-full)",
              border: "1px solid var(--color-border)",
              color: "var(--color-foreground-muted)",
              fontWeight: 500,
              fontSize: "0.9375rem",
              textDecoration: "none",
              background: "transparent",
            }}
          >
            {ctaSecondaryLabel}
          </a>
        </motion.div>
      </div>

      {/* ── 3D Marquee stage ── */}
      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ duration: 0.8, delay: 0.4 }}
        style={{
          position: "absolute",
          bottom: 0,
          left: 0,
          right: 0,
          height: "clamp(9rem, 20vw, 16rem)",
          perspective: "800px",
          perspectiveOrigin: "50% 0%",
          overflow: "hidden",
        }}
      >
        {/* Gradient fade — top edge */}
        <div
          aria-hidden
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            height: "55%",
            background:
              "linear-gradient(to bottom, var(--color-background) 0%, transparent 100%)",
            zIndex: 1,
            pointerEvents: "none",
          }}
        />
        {/* Gradient fade — sides */}
        <div
          aria-hidden
          style={{
            position: "absolute",
            inset: 0,
            background:
              "linear-gradient(to right, var(--color-background) 0%, transparent 15%, transparent 85%, var(--color-background) 100%)",
            zIndex: 1,
            pointerEvents: "none",
          }}
        />

        {/* Tilted 3D wrapper */}
        <div
          style={{
            transform: "rotateX(18deg)",
            transformStyle: "preserve-3d",
            paddingTop: "1rem",
          }}
        >
          <MarqueeTrack items={marqueeItems} direction={1} duration={28} />
          <MarqueeTrack
            items={[...marqueeItems].reverse()}
            direction={-1}
            duration={22}
            offsetY={4}
            dimmed
          />
        </div>
      </motion.div>
    </section>
  );
}

Avis

Hero 3D Marquee — React Hero Section — Incubator