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