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",
                        }}
                      >
                        &ldquo;{item.content}&rdquo;
                      </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>
  );
}

Reviews

Testimonials 3D Stack — React Testimonials Section — Incubator