Retour au catalogue

Gallery 3D Wall

Mur de photos en perspective 3D avec tilt reactif au curseur. Les images reagissent au mouvement de la souris creant une experience immersive.

gallerycomplex Both Responsive a11y
boldelegantagencyportfoliobeautygrid
Theme
"use client";

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

interface GalleryItem {
  label: string;
  color: string;
}

interface Gallery3dWallProps {
  title?: string;
  subtitle?: string;
  items?: GalleryItem[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const SPRING = { stiffness: 120, damping: 20, mass: 0.8 };

export default function Gallery3dWall({
  title = "Galerie",
  subtitle = "Collection",
  items = [],
}: Gallery3dWallProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mouseX = useMotionValue(0);
  const mouseY = useMotionValue(0);
  const rotateX = useSpring(useMotionValue(0), SPRING);
  const rotateY = useSpring(useMotionValue(0), SPRING);

  function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect) return;
    const cx = (e.clientX - rect.left) / rect.width - 0.5;
    const cy = (e.clientY - rect.top) / rect.height - 0.5;
    mouseX.set(cx);
    mouseY.set(cy);
    rotateY.set(cx * 8);
    rotateX.set(cy * -6);
  }

  function handleMouseLeave() {
    rotateX.set(0);
    rotateY.set(0);
  }

  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y-lg)",
        paddingBottom: "var(--section-padding-y-lg)",
        background: "var(--color-background)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.6, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "4rem" }}
        >
          <p
            style={{
              fontSize: "0.8125rem",
              fontWeight: 600,
              textTransform: "uppercase",
              letterSpacing: "0.1em",
              color: "var(--color-accent)",
              marginBottom: "0.5rem",
            }}
          >
            {subtitle}
          </p>
          <h2
            style={{
              fontFamily: "var(--font-sans)",
              fontSize: "clamp(2rem, 4vw, 3.5rem)",
              fontWeight: 700,
              letterSpacing: "-0.03em",
              color: "var(--color-foreground)",
            }}
          >
            {title}
          </h2>
        </motion.div>

        <div
          ref={containerRef}
          onMouseMove={handleMouseMove}
          onMouseLeave={handleMouseLeave}
          style={{ perspective: "1200px", cursor: "default" }}
        >
          <motion.div
            style={{
              rotateX,
              rotateY,
              display: "grid",
              gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
              gap: "1.25rem",
              transformStyle: "preserve-3d",
            }}
          >
            {items.map((item, i) => (
              <motion.div
                key={item.label}
                initial={{ opacity: 0, z: -60, scale: 0.9 }}
                whileInView={{ opacity: 1, z: 0, scale: 1 }}
                viewport={{ once: true, margin: "-40px" }}
                transition={{ duration: 0.65, delay: i * 0.05, ease: EASE }}
                style={{ transformStyle: "preserve-3d" }}
              >
                <div
                  style={{
                    position: "relative",
                    aspectRatio: "4/3",
                    borderRadius: "var(--radius-lg)",
                    overflow: "hidden",
                    background: item.color || "var(--color-accent-subtle)",
                    boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
                    transition: "box-shadow 0.4s ease, transform 0.4s ease",
                  }}
                >
                  <div
                    style={{
                      position: "absolute",
                      inset: 0,
                      display: "flex",
                      flexDirection: "column",
                      alignItems: "center",
                      justifyContent: "center",
                      gap: "0.5rem",
                    }}
                  >
                    <Image
                      style={{
                        width: 28,
                        height: 28,
                        color: "var(--color-foreground-muted)",
                        opacity: 0.35,
                      }}
                    />
                    <span
                      style={{
                        fontSize: "0.75rem",
                        fontWeight: 500,
                        color: "var(--color-foreground-muted)",
                        opacity: 0.5,
                      }}
                    >
                      {item.label}
                    </span>
                  </div>
                </div>
              </motion.div>
            ))}
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Avis

Gallery 3D Wall — React Gallery Section — Incubator