Retour au catalogue

Testimonials Audio Visual

Temoignages avec visualisation de forme d'onde audio animee en CSS. Effet podcast immersif.

testimonialsmedium Both Responsive a11y
boldplayfulsaaseducationuniversalstacked
Theme
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Play, Pause, Mic } from "lucide-react";

interface Testimonial {
  id: string;
  content: string;
  authorName: string;
  authorRole: string;
  duration: string;
}

interface TestimonialsAudioVisualProps {
  title?: string;
  subtitle?: string;
  testimonials?: Testimonial[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const BAR_COUNT = 32;

function WaveformBars({ playing }: { playing: boolean }) {
  return (
    <div className="flex items-center gap-[2px] h-8">
      {Array.from({ length: BAR_COUNT }).map((_, i) => {
        const baseHeight = 20 + Math.sin(i * 0.8) * 60 + Math.cos(i * 1.3) * 20;
        return (
          <motion.div
            key={i}
            className="w-[3px] rounded-full"
            style={{ background: "var(--color-accent)" }}
            animate={
              playing
                ? { height: [`${baseHeight}%`, `${20 + Math.random() * 80}%`, `${baseHeight}%`] }
                : { height: `${baseHeight}%` }
            }
            transition={
              playing
                ? { duration: 0.4 + Math.random() * 0.3, repeat: Infinity, repeatType: "reverse", delay: i * 0.02 }
                : { duration: 0.4 }
            }
          />
        );
      })}
    </div>
  );
}

export default function TestimonialsAudioVisual({
  title = "Ecoutez nos clients",
  subtitle = "Temoignages",
  testimonials = [],
}: TestimonialsAudioVisualProps) {
  const [activeIdx, setActiveIdx] = useState<number | null>(null);

  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y)",
        paddingBottom: "var(--section-padding-y)",
        background: "var(--color-background)",
      }}
    >
      <div
        className="mx-auto"
        style={{
          maxWidth: "var(--container-max-width)",
          paddingLeft: "var(--container-padding-x)",
          paddingRight: "var(--container-padding-x)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 16 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
          className="text-center mb-14"
        >
          <div className="inline-flex items-center gap-2 mb-4">
            <Mic className="w-4 h-4" style={{ color: "var(--color-accent)" }} />
            <span className="text-xs font-semibold uppercase tracking-widest" style={{ color: "var(--color-accent)" }}>{subtitle}</span>
          </div>
          <h2 className="text-3xl md:text-4xl font-bold tracking-tight" style={{ color: "var(--color-foreground)" }}>{title}</h2>
        </motion.div>

        <div className="flex flex-col gap-4 max-w-2xl mx-auto">
          {testimonials.map((t, i) => {
            const isPlaying = activeIdx === i;
            return (
              <motion.div
                key={t.id}
                initial={{ opacity: 0, y: 20 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true }}
                transition={{ duration: 0.5, delay: i * 0.1, ease: EASE }}
                className="rounded-xl p-5"
                style={{
                  background: "var(--color-background-card)",
                  border: isPlaying ? "1px solid var(--color-accent-border)" : "1px solid var(--color-border)",
                }}
              >
                <div className="flex items-center gap-4">
                  <button
                    onClick={() => setActiveIdx(isPlaying ? null : i)}
                    className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 cursor-pointer"
                    style={{ background: "var(--color-accent)", color: "var(--color-foreground-on-dark)" }}
                    aria-label={isPlaying ? "Pause" : "Ecouter"}
                  >
                    {isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
                  </button>

                  <div className="flex-1 overflow-hidden">
                    <WaveformBars playing={isPlaying} />
                  </div>

                  <span className="text-xs font-mono shrink-0" style={{ color: "var(--color-foreground-light)" }}>
                    {t.duration}
                  </span>
                </div>

                <AnimatePresence>
                  {isPlaying && (
                    <motion.div
                      initial={{ opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={{ opacity: 0, height: 0 }}
                      transition={{ duration: 0.3, ease: EASE }}
                      className="overflow-hidden"
                    >
                      <p className="text-sm leading-relaxed mt-4 pt-4" style={{ color: "var(--color-foreground-muted)", borderTop: "1px solid var(--color-border)" }}>
                        &ldquo;{t.content}&rdquo;
                      </p>
                    </motion.div>
                  )}
                </AnimatePresence>

                <div className="flex items-center gap-2 mt-3">
                  <div className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--color-accent-subtle)", color: "var(--color-accent)" }}>
                    {t.authorName.charAt(0)}
                  </div>
                  <span className="text-sm font-medium" style={{ color: "var(--color-foreground)" }}>{t.authorName}</span>
                  <span className="text-xs" style={{ color: "var(--color-foreground-light)" }}>{t.authorRole}</span>
                </div>
              </motion.div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Reviews

Testimonials Audio Visual — React Testimonials Section — Incubator