Retour au catalogue
Testimonials 3D Stack
Cards testimonials empilées comme un jeu de cartes physiques avec profondeur (Y offset + scale + ombre). La card du dessus sort en arc avec AnimatePresence, les suivantes montent. Auto-rotate toutes les 3.5s, navigation manuelle prev/next.
testimonialscomplex Both Responsive a11y
elegantboldluxurysaasagencyuniversalstackedcentered
Theme
"use client";
import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface Testimonial {
id: string;
content: string;
authorName: string;
authorRole: string;
company?: string;
rating?: number;
}
interface Testimonials3dStackProps {
title?: string;
subtitle?: string;
badge?: string;
testimonials?: Testimonial[];
autoPlayInterval?: number;
}
const SPRING = { type: "spring", stiffness: 280, damping: 32, mass: 1 } as const;
const EASE_OUT = [0.16, 1, 0.3, 1] as const;
const STACK_OFFSETS = [
{ y: 0, scale: 1, opacity: 1, shadow: "0 24px 80px -8px rgba(0,0,0,0.22), 0 8px 32px -4px rgba(0,0,0,0.12)" },
{ y: 14, scale: 0.96, opacity: 0.85, shadow: "0 12px 40px -4px rgba(0,0,0,0.14)" },
{ y: 26, scale: 0.92, opacity: 0.6, shadow: "0 6px 20px -2px rgba(0,0,0,0.08)" },
];
function StarRating({ rating }: { rating: number }) {
return (
<div style={{ display: "flex", gap: 3, marginBottom: "1.25rem" }}>
{Array.from({ length: 5 }).map((_, i) => (
<svg key={i} width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M7 1l1.545 3.13L12 4.635l-2.5 2.435.59 3.44L7 8.875l-3.09 1.635.59-3.44L2 4.635l3.455-.505L7 1z"
fill={i < rating ? "var(--color-accent)" : "var(--color-border)"}
/>
</svg>
))}
</div>
);
}
function InitialAvatar({ name, index }: { name: string; index: number }) {
const hues = [220, 280, 160, 30, 340];
const hue = hues[index % hues.length];
return (
<div
style={{
width: 44,
height: 44,
borderRadius: "var(--radius-full)",
background: `hsl(${hue}, 60%, 55%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: "1rem",
fontWeight: 700,
color: "#fff",
letterSpacing: "-0.01em",
}}
>
{name.charAt(0)}
</div>
);
}
export default function Testimonials3dStack({
title = "Trusted by makers worldwide",
subtitle = "Real words from real customers who built something great.",
badge = "Testimonials",
testimonials = [],
autoPlayInterval = 3500,
}: Testimonials3dStackProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [direction, setDirection] = useState<1 | -1>(1);
const n = testimonials.length;
const advance = useCallback(
(dir: 1 | -1) => {
setDirection(dir);
setActiveIndex((i) => (i + dir + n) % n);
},
[n]
);
useEffect(() => {
if (n < 2) return;
const id = setInterval(() => advance(1), autoPlayInterval);
return () => clearInterval(id);
}, [advance, autoPlayInterval, n]);
if (n === 0) return null;
// Build visible stack: [active, active+1, active+2]
const stackIndices = [0, 1, 2].map((offset) => (activeIndex + offset) % n);
return (
<section
style={{
backgroundColor: "var(--color-background)",
paddingTop: "var(--section-padding-y, 6rem)",
paddingBottom: "var(--section-padding-y, 6rem)",
overflow: "hidden",
}}
>
<div
style={{
maxWidth: "var(--container-max-width, 72rem)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
}}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55, ease: EASE_OUT }}
style={{ textAlign: "center", marginBottom: "4rem" }}
>
{badge && (
<span
style={{
display: "inline-block",
padding: "0.375rem 1rem",
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-foreground-muted)",
marginBottom: "1.25rem",
}}
>
{badge}
</span>
)}
<h2
style={{
fontSize: "clamp(1.875rem, 3.5vw, 3rem)",
fontWeight: 700,
letterSpacing: "-0.03em",
lineHeight: 1.15,
color: "var(--color-foreground)",
marginBottom: "0.75rem",
}}
>
{title}
</h2>
<p style={{ fontSize: "1.0625rem", color: "var(--color-foreground-muted)", lineHeight: 1.65 }}>
{subtitle}
</p>
</motion.div>
{/* Stack area */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "3rem",
}}
>
<div
style={{
position: "relative",
width: "100%",
maxWidth: 560,
height: 300,
cursor: "pointer",
}}
onClick={() => advance(1)}
role="button"
aria-label="Next testimonial"
>
{/* Render from bottom to top so card 0 is on top */}
{[...stackIndices].reverse().map((testimonialIndex, reversedPos) => {
const pos = 2 - reversedPos; // 0 = top card
const offset = STACK_OFFSETS[pos];
const item = testimonials[testimonialIndex];
return (
<AnimatePresence key={testimonialIndex} mode="popLayout">
<motion.div
key={`${testimonialIndex}-${activeIndex}`}
layout
initial={
pos === 0
? { opacity: 0, y: direction === 1 ? -120 : 120, rotate: direction === 1 ? -6 : 6, scale: 0.9 }
: false
}
animate={{
y: offset.y,
scale: offset.scale,
opacity: offset.opacity,
rotate: 0,
}}
exit={{ opacity: 0, y: -160, rotate: -8, scale: 0.88, transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] } }}
transition={pos === 0 ? { ...SPRING, delay: 0.04 } : { ...SPRING, delay: pos * 0.06 }}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10 - pos,
transformOrigin: "top center",
boxShadow: offset.shadow,
borderRadius: "var(--radius-xl, 1.5rem)",
pointerEvents: pos === 0 ? "auto" : "none",
}}
>
{/* Card */}
<div
style={{
padding: "2rem 2.25rem",
borderRadius: "var(--radius-xl, 1.5rem)",
backgroundColor: "var(--color-background-card)",
border: "1px solid var(--color-border)",
}}
>
{item.rating !== undefined && <StarRating rating={item.rating} />}
<p
style={{
fontSize: "1.0625rem",
lineHeight: 1.72,
color: "var(--color-foreground)",
marginBottom: "1.75rem",
fontStyle: "normal",
}}
>
“{item.content}”
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.875rem" }}>
<InitialAvatar name={item.authorName} index={testimonialIndex} />
<div>
<p
style={{
fontWeight: 600,
fontSize: "0.9375rem",
color: "var(--color-foreground)",
lineHeight: 1.3,
}}
>
{item.authorName}
</p>
<p style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)", marginTop: 2 }}>
{item.authorRole}
{item.company && (
<span style={{ opacity: 0.7 }}> · {item.company}</span>
)}
</p>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
})}
</div>
{/* Controls */}
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
<button
onClick={(e) => { e.stopPropagation(); advance(-1); }}
aria-label="Previous"
style={{
width: 44,
height: 44,
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
backgroundColor: "var(--color-background-card)",
color: "var(--color-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<ChevronLeft size={18} />
</button>
<div style={{ display: "flex", gap: 6 }}>
{testimonials.map((_, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setDirection(i > activeIndex ? 1 : -1); setActiveIndex(i); }}
aria-label={`Testimonial ${i + 1}`}
style={{
width: i === activeIndex ? 24 : 8,
height: 8,
borderRadius: "var(--radius-full)",
border: "none",
backgroundColor: i === activeIndex ? "var(--color-accent)" : "var(--color-border)",
cursor: "pointer",
padding: 0,
transition: "width 0.3s ease, background-color 0.3s ease",
}}
/>
))}
</div>
<button
onClick={(e) => { e.stopPropagation(); advance(1); }}
aria-label="Next"
style={{
width: 44,
height: 44,
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
backgroundColor: "var(--color-background-card)",
color: "var(--color-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
</section>
);
}