Retour au catalogue
Portfolio 3D Book
Portfolio presente comme un livre 3D dont on tourne les pages. Effet flip realiste avec perspective et ombres dynamiques.
portfoliocomplex Both Responsive a11y
elegantelegantagencyportfolioeducationcentered
Theme
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight, BookOpen } from "lucide-react";
interface BookPage {
title: string;
description: string;
image: string;
category: string;
}
interface Portfolio3dBookProps {
title?: string;
subtitle?: string;
pages?: BookPage[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function Portfolio3dBook({
title = "Notre book",
subtitle = "Feuilletez nos projets",
pages = [],
}: Portfolio3dBookProps) {
const [current, setCurrent] = useState(0);
const [direction, setDirection] = useState(1);
const goTo = (idx: number) => {
setDirection(idx > current ? 1 : -1);
setCurrent(idx);
};
const prev = () => current > 0 && goTo(current - 1);
const next = () => current < pages.length - 1 && goTo(current + 1);
const page = pages[current];
return (
<section style={{ paddingTop: "var(--section-padding-y, 6rem)", paddingBottom: "var(--section-padding-y, 6rem)", background: "var(--color-background)" }}>
<div style={{ maxWidth: "var(--container-max-width, 72rem)", 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.6, ease: EASE }} style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem" }}>
<BookOpen style={{ width: 18, height: 18, color: "var(--color-accent)" }} />
<p style={{ fontSize: "0.8125rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--color-accent)" }}>{subtitle}</p>
</div>
<h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 700, letterSpacing: "-0.03em", color: "var(--color-foreground)" }}>{title}</h2>
</motion.div>
<div style={{ perspective: "1200px", maxWidth: "56rem", margin: "0 auto" }}>
<AnimatePresence mode="wait" custom={direction}>
{page && (
<motion.div
key={current}
custom={direction}
initial={{ rotateY: direction * 90, opacity: 0 }}
animate={{ rotateY: 0, opacity: 1 }}
exit={{ rotateY: direction * -90, opacity: 0 }}
transition={{ duration: 0.6, ease: EASE }}
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
borderRadius: "var(--radius-xl, 1.5rem)",
overflow: "hidden",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
boxShadow: "0 16px 48px color-mix(in srgb, var(--color-foreground) 10%, transparent)",
transformStyle: "preserve-3d",
}}
>
<div style={{ aspectRatio: "3/4", overflow: "hidden" }}>
<img src={page.image} alt={page.title} style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
</div>
<div style={{ padding: "3rem 2.5rem", display: "flex", flexDirection: "column", justifyContent: "center" }}>
<span style={{ fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-accent)", marginBottom: "1rem" }}>{page.category}</span>
<h3 style={{ fontSize: "1.75rem", fontWeight: 700, letterSpacing: "-0.02em", color: "var(--color-foreground)", lineHeight: 1.2 }}>{page.title}</h3>
<p style={{ fontSize: "0.9375rem", color: "var(--color-foreground-muted)", lineHeight: 1.7, marginTop: "1rem" }}>{page.description}</p>
<div style={{ marginTop: "2rem", fontSize: "0.8125rem", color: "var(--color-foreground-muted)" }}>
Page {current + 1} / {pages.length}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div style={{ display: "flex", justifyContent: "center", gap: "1rem", marginTop: "2rem" }}>
<button onClick={prev} disabled={current === 0} style={{ width: "44px", height: "44px", borderRadius: "50%", border: "1px solid var(--color-border)", background: current === 0 ? "var(--color-background)" : "var(--color-background-card)", cursor: current === 0 ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", opacity: current === 0 ? 0.4 : 1 }}>
<ChevronLeft style={{ width: 18, height: 18, color: "var(--color-foreground)" }} />
</button>
{pages.map((_, i) => (
<button key={i} onClick={() => goTo(i)} style={{ width: "10px", height: "10px", borderRadius: "50%", border: "none", background: i === current ? "var(--color-accent)" : "var(--color-border)", cursor: "pointer", transition: "background 0.3s" }} />
))}
<button onClick={next} disabled={current === pages.length - 1} style={{ width: "44px", height: "44px", borderRadius: "50%", border: "1px solid var(--color-border)", background: current === pages.length - 1 ? "var(--color-background)" : "var(--color-background-card)", cursor: current === pages.length - 1 ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", opacity: current === pages.length - 1 ? 0.4 : 1 }}>
<ChevronRight style={{ width: 18, height: 18, color: "var(--color-foreground)" }} />
</button>
</div>
</div>
</section>
);
}