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

Avis

Portfolio 3D Book — React Portfolio Section — Incubator