Retour au catalogue

Testimonials Cards

Cartes temoignages empilees avec effet de profondeur. Hover revele le temoignage complet.

testimonialsmedium Both Responsive a11y
elegantlightuniversalstacked
Theme
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Star, ChevronLeft, ChevronRight } from "lucide-react";

interface TestimonialItem {
  id: string;
  content: string;
  authorName: string;
  authorRole: string;
  rating: number;
}

interface TestimonialsCardsProps {
  title?: string;
  subtitle?: string;
  badge?: string;
  testimonials?: TestimonialItem[];
}

export default function TestimonialsCards({
  title = "Ce que nos clients disent",
  subtitle,
  badge = "Temoignages",
  testimonials = [],
}: TestimonialsCardsProps) {
  const [current, setCurrent] = useState(0);
  const n = testimonials.length;

  if (n === 0) return null;

  const prev = () => setCurrent((i) => (i - 1 + n) % n);
  const next = () => setCurrent((i) => (i + 1) % n);

  return (
    <section
      style={{
        backgroundColor: "var(--color-background-alt)",
        paddingTop: "var(--section-padding-y)",
        paddingBottom: "var(--section-padding-y)",
      }}
    >
      <div
        className="mx-auto"
        style={{
          maxWidth: "var(--container-max-width)",
          paddingLeft: "var(--container-padding-x)",
          paddingRight: "var(--container-padding-x)",
        }}
      >
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
          {/* Left: Header */}
          <motion.div
            initial={{ opacity: 0, x: -20 }}
            whileInView={{ opacity: 1, x: 0 }}
            viewport={{ once: true }}
            transition={{ duration: 0.5 }}
          >
            {badge && (
              <span
                className="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-semibold uppercase tracking-widest"
                style={{
                  border: "1px solid var(--color-accent-border)",
                  color: "var(--color-accent-hover)",
                }}
              >
                {badge}
              </span>
            )}
            <h2
              className="mt-4 text-3xl md:text-4xl font-bold tracking-tight"
              style={{ color: "var(--color-foreground)" }}
            >
              {title}
            </h2>
            {subtitle && (
              <p
                className="mt-3 text-base"
                style={{ color: "var(--color-foreground-muted)" }}
              >
                {subtitle}
              </p>
            )}

            <div className="flex items-center gap-3 mt-8">
              <button
                onClick={prev}
                className="w-10 h-10 rounded-full flex items-center justify-center cursor-pointer transition-colors"
                style={{
                  border: "1px solid var(--color-border)",
                  color: "var(--color-foreground-muted)",
                }}
                aria-label="Precedent"
              >
                <ChevronLeft className="h-4 w-4" />
              </button>
              <button
                onClick={next}
                className="w-10 h-10 rounded-full flex items-center justify-center cursor-pointer transition-colors"
                style={{
                  border: "1px solid var(--color-border)",
                  color: "var(--color-foreground-muted)",
                }}
                aria-label="Suivant"
              >
                <ChevronRight className="h-4 w-4" />
              </button>
              <span
                className="text-xs font-medium ml-2"
                style={{ color: "var(--color-foreground-light)" }}
              >
                {current + 1} / {n}
              </span>
            </div>
          </motion.div>

          {/* Right: Stacked cards */}
          <div className="relative h-[320px]">
            <AnimatePresence mode="popLayout">
              {[0, 1, 2].map((offset) => {
                const idx = (current + offset) % n;
                const t = testimonials[idx];
                const isTop = offset === 0;
                return (
                  <motion.div
                    key={`${idx}-${current}`}
                    initial={{ opacity: 0, y: 40, scale: 0.95 }}
                    animate={{
                      opacity: 1 - offset * 0.25,
                      y: offset * 16,
                      scale: 1 - offset * 0.04,
                      zIndex: 3 - offset,
                    }}
                    exit={{ opacity: 0, y: -40, scale: 0.95 }}
                    transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
                    className="absolute inset-x-0 top-0 rounded-2xl p-8"
                    style={{
                      backgroundColor: "var(--color-background-card)",
                      border: isTop
                        ? "1px solid var(--color-accent-border)"
                        : "1px solid var(--color-border)",
                      boxShadow: isTop
                        ? "0 12px 40px -10px rgba(0,0,0,0.1)"
                        : "0 4px 16px -4px rgba(0,0,0,0.05)",
                    }}
                  >
                    <p
                      className="text-base leading-relaxed"
                      style={{ color: "var(--color-foreground-muted)" }}
                    >
                      &ldquo;{t.content}&rdquo;
                    </p>

                    <div className="flex gap-0.5 mt-4">
                      {Array.from({ length: 5 }).map((_, i) => (
                        <Star
                          key={i}
                          className="h-3 w-3"
                          style={{
                            fill:
                              i < t.rating
                                ? "var(--color-accent)"
                                : "transparent",
                            color:
                              i < t.rating
                                ? "var(--color-accent)"
                                : "var(--color-border)",
                          }}
                        />
                      ))}
                    </div>

                    <div className="flex items-center gap-3 mt-6">
                      <div
                        className="w-10 h-10 rounded-full flex items-center justify-center"
                        style={{
                          backgroundColor: "var(--color-accent-subtle)",
                        }}
                      >
                        <span
                          className="text-sm font-bold"
                          style={{ color: "var(--color-accent-hover)" }}
                        >
                          {t.authorName.charAt(0)}
                        </span>
                      </div>
                      <div>
                        <p
                          className="text-sm font-semibold"
                          style={{ color: "var(--color-foreground)" }}
                        >
                          {t.authorName}
                        </p>
                        <p
                          className="text-xs"
                          style={{ color: "var(--color-foreground-light)" }}
                        >
                          {t.authorRole}
                        </p>
                      </div>
                    </div>
                  </motion.div>
                );
              })}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  );
}

Avis

Testimonials Cards — React Testimonials Section — Incubator