Web InnoventixFreeCode

Video Testimonial

Original · free

video testimonial cards

byWeb InnoventixReact + Tailwind
tstxvideotestimonials
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/tstx-video.json
tstx-video.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Category = "startups" | "enterprise" | "agencies";
type Filter = "all" | Category;

interface Testimonial {
  id: string;
  name: string;
  role: string;
  company: string;
  category: Category;
  durationSec: number;
  quote: string;
  metric: { value: string; label: string };
  gradient: string;
  ring: string;
}

const FILTERS: ReadonlyArray<{ id: Filter; label: string }> = [
  { id: "all", label: "All stories" },
  { id: "startups", label: "Startups" },
  { id: "enterprise", label: "Enterprise" },
  { id: "agencies", label: "Agencies" },
];

const CATEGORY_LABEL: Record<Category, string> = {
  startups: "Startup",
  enterprise: "Enterprise",
  agencies: "Agency",
};

const TESTIMONIALS: ReadonlyArray<Testimonial> = [
  {
    id: "t1",
    name: "Maya Chen",
    role: "Head of Growth",
    company: "Brightline Labs",
    category: "startups",
    durationSec: 84,
    quote:
      "We plugged Cadence into our onboarding on a Friday and had our first automated cohort live by Monday. Time-to-value went from three weeks to four days, and support tickets fell off a cliff.",
    metric: { value: "-78%", label: "time to first value" },
    gradient: "from-indigo-500 via-violet-500 to-sky-500",
    ring: "ring-indigo-400/40",
  },
  {
    id: "t2",
    name: "David Okafor",
    role: "VP of Operations",
    company: "Northgate Systems",
    category: "enterprise",
    durationSec: 132,
    quote:
      "Rolling software out to 3,000 people usually means a six-month change-management slog. With Cadence we hit 90% adoption in the first quarter without a single mandatory training session.",
    metric: { value: "90%", label: "adoption in Q1" },
    gradient: "from-sky-500 via-indigo-500 to-violet-500",
    ring: "ring-sky-400/40",
  },
  {
    id: "t3",
    name: "Priya Nair",
    role: "Founder",
    company: "Studio Kelp",
    category: "agencies",
    durationSec: 97,
    quote:
      "As an agency we juggle a dozen client workspaces at once. Cadence is the first tool that didn't buckle under that mess. We took on four new retainers without adding headcount.",
    metric: { value: "+4", label: "retainers, same team" },
    gradient: "from-emerald-500 via-sky-500 to-indigo-500",
    ring: "ring-emerald-400/40",
  },
  {
    id: "t4",
    name: "Lucas Moreau",
    role: "CTO",
    company: "Fernpath",
    category: "startups",
    durationSec: 108,
    quote:
      "The API is what sold my engineers. We replaced three brittle internal scripts with one Cadence workflow and reclaimed a full sprint of maintenance every single month.",
    metric: { value: "40 hrs", label: "saved per month" },
    gradient: "from-violet-500 via-indigo-500 to-sky-500",
    ring: "ring-violet-400/40",
  },
  {
    id: "t5",
    name: "Hannah Weiss",
    role: "Director of RevOps",
    company: "Vantera",
    category: "enterprise",
    durationSec: 141,
    quote:
      "Our forecasts used to live in seven spreadsheets and one very anxious analyst. Now the numbers reconcile themselves nightly, and I walk into every board meeting with data I actually trust.",
    metric: { value: "7 → 1", label: "tools consolidated" },
    gradient: "from-amber-500 via-rose-500 to-violet-500",
    ring: "ring-rose-400/40",
  },
  {
    id: "t6",
    name: "Theo Alvarez",
    role: "Creative Director",
    company: "Halcyon Co.",
    category: "agencies",
    durationSec: 76,
    quote:
      "I was skeptical an ops tool could feel this good to use. My team opens it on purpose. Project throughput is up by a third and nobody is begging for the old system back.",
    metric: { value: "+33%", label: "project throughput" },
    gradient: "from-rose-500 via-amber-500 to-emerald-500",
    ring: "ring-amber-400/40",
  },
];

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';

const STYLES = `
@keyframes tstxv-ring{0%{transform:scale(1);opacity:.5}70%{transform:scale(2);opacity:0}100%{transform:scale(2);opacity:0}}
@keyframes tstxv-eq{0%,100%{transform:scaleY(.3)}50%{transform:scaleY(1)}}
@keyframes tstxv-blob{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(6%,-9%,0) scale(1.18)}}
.tstxv-ring{animation:tstxv-ring 2.6s cubic-bezier(.4,0,.2,1) infinite}
.tstxv-bar{transform-origin:bottom;animation:tstxv-eq .9s ease-in-out infinite}
.tstxv-blob{animation:tstxv-blob 15s ease-in-out infinite}
@media (prefers-reduced-motion: reduce){
  .tstxv-ring,.tstxv-bar,.tstxv-blob{animation:none!important}
}
`;

function fmt(sec: number): string {
  const s = Math.max(0, Math.floor(sec));
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${m}:${r.toString().padStart(2, "0")}`;
}

function PlayIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.79-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
    </svg>
  );
}

function PauseIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M8 4.5A1.5 1.5 0 0 0 6.5 6v12a1.5 1.5 0 0 0 3 0V6A1.5 1.5 0 0 0 8 4.5Zm8 0A1.5 1.5 0 0 0 14.5 6v12a1.5 1.5 0 0 0 3 0V6A1.5 1.5 0 0 0 16 4.5Z" />
    </svg>
  );
}

function VolumeIcon({ muted, className }: { muted: boolean; className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
      <path d="M11 5 6 9H3v6h3l5 4V5Z" fill="currentColor" stroke="none" />
      {muted ? (
        <path d="m16 9 5 6m0-6-5 6" />
      ) : (
        <>
          <path d="M16 8.5a5 5 0 0 1 0 7" />
          <path d="M18.5 6a8.5 8.5 0 0 1 0 12" />
        </>
      )}
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className={className}>
      <path d="M6 6l12 12M18 6 6 18" />
    </svg>
  );
}

function QuoteMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M9.5 5C6.46 6 4.5 8.9 4.5 12.4V19h6.6v-6.7H7.9c.05-2.2 1.06-3.6 3.2-4.2L9.5 5Zm9 0c-3.04 1-5 3.9-5 7.4V19h6.6v-6.7h-3.2c.05-2.2 1.06-3.6 3.2-4.2L18.5 5Z" />
    </svg>
  );
}

function StarIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="m12 3 2.6 5.3 5.9.86-4.25 4.14 1 5.86L12 16.9l-5.25 2.26 1-5.86L3.5 9.16l5.9-.86L12 3Z" />
    </svg>
  );
}

function Poster({
  t,
  overlay,
  playing,
}: {
  t: Testimonial;
  overlay?: boolean;
  playing?: boolean;
}) {
  const initials = t.name
    .split(" ")
    .map((p) => p[0])
    .join("");
  return (
    <>
      <div className={`absolute inset-0 bg-gradient-to-br ${t.gradient}`} />
      <div className="tstxv-blob absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/25 blur-2xl" />
      <div className="absolute inset-0 opacity-[0.14] [background-image:radial-gradient(circle_at_1px_1px,#fff_1px,transparent_0)] [background-size:14px_14px]" />
      <div className="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent" />
      <span
        aria-hidden="true"
        className="absolute right-4 top-3 select-none text-7xl font-black leading-none text-white/15"
      >
        {initials}
      </span>
      {overlay ? (
        <span className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <span
            className={`${playing ? "" : "tstxv-ring"} absolute h-20 w-20 rounded-full bg-white/40`}
          />
          <span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-slate-900 shadow-xl">
            {playing ? <PauseIcon className="h-6 w-6" /> : <PlayIcon className="ml-0.5 h-6 w-6" />}
          </span>
        </span>
      ) : null}
    </>
  );
}

export default function TstxVideo() {
  const reduce = useReducedMotion();
  const [filter, setFilter] = useState<Filter>("all");
  const [activeId, setActiveId] = useState<string | null>(null);
  const [playing, setPlaying] = useState(false);
  const [muted, setMuted] = useState(false);
  const [progress, setProgress] = useState(0);

  const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const dialogRef = useRef<HTMLDivElement>(null);
  const closeRef = useRef<HTMLButtonElement>(null);
  const restoreRef = useRef<HTMLElement | null>(null);

  const active = TESTIMONIALS.find((t) => t.id === activeId) ?? null;
  const durationSec = active?.durationSec ?? 1;
  const shown =
    filter === "all"
      ? TESTIMONIALS
      : TESTIMONIALS.filter((t) => t.category === filter);

  useEffect(() => {
    setProgress(0);
    setMuted(false);
    setPlaying(activeId != null);
  }, [activeId]);

  useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => {
      setProgress((p) => Math.min(100, p + 100 / (durationSec * 10)));
    }, 100);
    return () => window.clearInterval(id);
  }, [playing, durationSec]);

  useEffect(() => {
    if (progress >= 100) setPlaying(false);
  }, [progress]);

  useEffect(() => {
    if (!activeId) return;
    restoreRef.current = document.activeElement as HTMLElement | null;
    const t = window.setTimeout(() => closeRef.current?.focus(), 0);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.clearTimeout(t);
      document.body.style.overflow = prevOverflow;
      restoreRef.current?.focus();
    };
  }, [activeId]);

  function close() {
    setActiveId(null);
  }

  function togglePlay() {
    setPlaying((p) => {
      if (!p && progress >= 100) setProgress(0);
      return !p;
    });
  }

  function onSeek(e: ChangeEvent<HTMLInputElement>) {
    setProgress(Number(e.target.value));
  }

  function onDialogKey(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === "Escape") {
      e.stopPropagation();
      close();
      return;
    }
    if (e.key !== "Tab") return;
    const nodes = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);
    if (!nodes || nodes.length === 0) return;
    const first = nodes[0];
    const last = nodes[nodes.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
    let next = index;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % FILTERS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (index - 1 + FILTERS.length) % FILTERS.length;
    else return;
    e.preventDefault();
    setFilter(FILTERS[next].id);
    radioRefs.current[next]?.focus();
  }

  const currentTime = (progress / 100) * durationSec;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-slate-900 sm:py-32 dark:bg-slate-950 dark:text-slate-100">
      <style>{STYLES}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.16),transparent_70%)] dark:opacity-100 dark:[background:radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.22),transparent_70%)]"
      />

      <div className="relative mx-auto max-w-6xl">
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 24 }}
          whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: reduce ? 0 : 0.6, ease: [0.22, 1, 0.36, 1] }}
          className="mx-auto max-w-2xl text-center"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 shadow-sm dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Customer stories
          </span>
          <h2 className="mt-6 text-balance text-4xl font-bold tracking-tight sm:text-5xl">
            Don&apos;t take our word for it. Take theirs.
          </h2>
          <p className="mt-4 text-pretty text-lg leading-relaxed text-slate-600 dark:text-slate-400">
            Ninety-second stories from the growth leads, operators, and founders who
            run their entire day on Cadence.
          </p>

          <div className="mt-6 flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-slate-500 dark:text-slate-400">
            <span className="inline-flex items-center gap-1.5 font-medium text-slate-700 dark:text-slate-200">
              <span className="flex text-amber-500">
                {Array.from({ length: 5 }).map((_, i) => (
                  <StarIcon key={i} className="h-4 w-4" />
                ))}
              </span>
              4.9 / 5 average
            </span>
            <span className="hidden h-4 w-px bg-slate-300 sm:block dark:bg-slate-700" />
            <span>2,400+ teams</span>
            <span className="hidden h-4 w-px bg-slate-300 sm:block dark:bg-slate-700" />
            <span>12 industries</span>
          </div>
        </motion.div>

        <div
          role="radiogroup"
          aria-label="Filter testimonials by team type"
          className="mt-10 flex flex-wrap items-center justify-center gap-2"
        >
          {FILTERS.map((f, i) => {
            const selected = filter === f.id;
            return (
              <button
                key={f.id}
                ref={(el) => {
                  radioRefs.current[i] = el;
                }}
                type="button"
                role="radio"
                aria-checked={selected}
                tabIndex={selected ? 0 : -1}
                onClick={() => setFilter(f.id)}
                onKeyDown={(e) => onRadioKey(e, i)}
                className={`rounded-full px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                  selected
                    ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                    : "border border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
                }`}
              >
                {f.label}
              </button>
            );
          })}
        </div>

        <motion.ul
          layout={!reduce}
          className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
        >
          <AnimatePresence mode="popLayout">
            {shown.map((t) => (
              <motion.li
                key={t.id}
                layout={!reduce}
                initial={reduce ? false : { opacity: 0, scale: 0.94 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
                transition={{ duration: reduce ? 0 : 0.4, ease: [0.22, 1, 0.36, 1] }}
                className="group flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition hover:shadow-lg hover:shadow-indigo-500/5 dark:border-slate-800 dark:bg-slate-900"
              >
                <button
                  type="button"
                  onClick={() => setActiveId(t.id)}
                  aria-label={`Play ${fmt(t.durationSec)} video testimonial from ${t.name}, ${t.role} at ${t.company}`}
                  className={`relative block aspect-video w-full overflow-hidden text-left ring-inset focus-visible:outline-none focus-visible:ring-2 ${t.ring} focus-visible:ring-2 focus-visible:ring-indigo-400`}
                >
                  <Poster t={t} overlay />
                  <span className="absolute left-3 top-3 rounded-full bg-slate-950/55 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-white backdrop-blur-sm">
                    {CATEGORY_LABEL[t.category]}
                  </span>
                  <span className="absolute right-3 top-3 rounded-full bg-slate-950/55 px-2 py-1 text-[11px] font-semibold tabular-nums text-white backdrop-blur-sm">
                    {fmt(t.durationSec)}
                  </span>
                  <span className="absolute inset-x-4 bottom-3">
                    <span className="block text-base font-semibold text-white drop-shadow">
                      {t.name}
                    </span>
                    <span className="block text-xs text-white/80 drop-shadow">
                      {t.role}, {t.company}
                    </span>
                  </span>
                </button>

                <div className="flex flex-1 flex-col p-5">
                  <QuoteMark className="h-5 w-5 text-indigo-400/70" />
                  <p className="mt-2 line-clamp-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                    {t.quote}
                  </p>
                  <div className="mt-4 flex items-center justify-between border-t border-slate-100 pt-4 dark:border-slate-800">
                    <span className="flex items-baseline gap-1.5">
                      <span className="text-lg font-bold tracking-tight text-slate-900 dark:text-white">
                        {t.metric.value}
                      </span>
                      <span className="text-xs text-slate-500 dark:text-slate-400">
                        {t.metric.label}
                      </span>
                    </span>
                    <span className="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 transition group-hover:gap-1.5 dark:text-indigo-400">
                      Watch
                      <PlayIcon className="h-3 w-3" />
                    </span>
                  </div>
                </div>
              </motion.li>
            ))}
          </AnimatePresence>
        </motion.ul>
      </div>

      <AnimatePresence>
        {active ? (
          <motion.div
            key="backdrop"
            initial={reduce ? { opacity: 1 } : { opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.2 }}
            onMouseDown={(e) => {
              if (e.target === e.currentTarget) close();
            }}
            onKeyDown={onDialogKey}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
          >
            <motion.div
              ref={dialogRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby="tstxv-dialog-name"
              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.94, y: 16 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
              transition={{ duration: reduce ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
              className="relative w-full max-w-3xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
            >
              <button
                ref={closeRef}
                type="button"
                onClick={close}
                aria-label="Close video"
                className="absolute right-3 top-3 z-10 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-950/50 text-white transition hover:bg-slate-950/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
              >
                <CloseIcon className="h-5 w-5" />
              </button>

              <div className="relative aspect-video w-full overflow-hidden">
                <Poster t={active} />
                <button
                  type="button"
                  onClick={togglePlay}
                  aria-label={playing ? "Pause video" : "Play video"}
                  className="absolute inset-0 flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
                >
                  <span className="flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-slate-900 shadow-xl transition group-hover:scale-105">
                    {playing ? (
                      <PauseIcon className="h-7 w-7" />
                    ) : (
                      <PlayIcon className="ml-0.5 h-7 w-7" />
                    )}
                  </span>
                </button>

                <div className="absolute inset-x-4 bottom-14 flex items-end justify-between">
                  <div>
                    <p id="tstxv-dialog-name" className="text-lg font-semibold text-white drop-shadow">
                      {active.name}
                    </p>
                    <p className="text-sm text-white/80 drop-shadow">
                      {active.role} · {active.company}
                    </p>
                  </div>
                  {playing && !muted ? (
                    <span className="flex h-6 items-end gap-1" aria-hidden="true">
                      {[0, 1, 2, 3].map((i) => (
                        <span
                          key={i}
                          className="tstxv-bar w-1 rounded-full bg-white/90"
                          style={{ height: "100%", animationDelay: `${i * 0.15}s` }}
                        />
                      ))}
                    </span>
                  ) : null}
                </div>

                <div className="absolute inset-x-3 bottom-3 flex items-center gap-3 rounded-xl bg-slate-950/45 px-3 py-2 backdrop-blur-md">
                  <button
                    type="button"
                    onClick={togglePlay}
                    aria-label={playing ? "Pause" : "Play"}
                    className="text-white transition hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-0"
                  >
                    {playing ? <PauseIcon className="h-5 w-5" /> : <PlayIcon className="h-5 w-5" />}
                  </button>
                  <span className="w-24 shrink-0 text-xs font-medium tabular-nums text-white/90">
                    {fmt(currentTime)} / {fmt(durationSec)}
                  </span>
                  <input
                    type="range"
                    min={0}
                    max={100}
                    step={0.5}
                    value={progress}
                    onChange={onSeek}
                    aria-label="Seek video"
                    className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/30 accent-indigo-400"
                  />
                  <button
                    type="button"
                    onClick={() => setMuted((m) => !m)}
                    aria-label={muted ? "Unmute" : "Mute"}
                    aria-pressed={muted}
                    className="text-white transition hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
                  >
                    <VolumeIcon muted={muted} className="h-5 w-5" />
                  </button>
                </div>
              </div>

              <div className="p-6 sm:p-8">
                <QuoteMark className="h-7 w-7 text-indigo-400" />
                <blockquote className="mt-3 text-pretty text-lg font-medium leading-relaxed text-slate-800 dark:text-slate-100">
                  {active.quote}
                </blockquote>
                <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-slate-100 pt-5 dark:border-slate-800">
                  <span className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm dark:bg-slate-800">
                    <span className="font-bold text-slate-900 dark:text-white">
                      {active.metric.value}
                    </span>
                    <span className="text-slate-500 dark:text-slate-400">
                      {active.metric.label}
                    </span>
                  </span>
                  <span className="text-sm text-slate-500 dark:text-slate-400">
                    {CATEGORY_LABEL[active.category]} · {fmt(active.durationSec)} story
                  </span>
                </div>
              </div>
            </motion.div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →
Three Card Testimonial Grid

Three Card Testimonial Grid

Original

A clean three-column grid of testimonial cards, each with a star rating, quote and an initial avatar with name and role.

Single Featured Quote

Single Featured Quote

Original

A large centred pull quote with a decorative quotation mark, five-star rating and a prominent author avatar for a single standout testimonial.

Logo Wall Marquee

Logo Wall Marquee

Original

A pair of infinitely scrolling, pause-on-hover marquees pairing a customer logo wall with compact quote cards, with reduced-motion support.

Masonry Testimonial Wall

Masonry Testimonial Wall

Original

A CSS-columns masonry wall of testimonial cards in varied lengths, each with a star rating and initial avatar for a natural, organic layout.

Aggregate Rating Spotlight

Aggregate Rating Spotlight

Original

A split layout pairing a large aggregate rating score, star row and overlapping avatar stack with two featured review cards.

Auto-Rotating Quote Carousel

Auto-Rotating Quote Carousel

Original

An auto-advancing testimonial carousel that slides and crossfades between quotes with a live progress bar, pause-on-hover, and dot plus arrow navigation.

Staggered Reveal Testimonial Grid

Staggered Reveal Testimonial Grid

Original

A testimonial grid whose cards spring in with a staggered scroll reveal and light up with an animated gradient border on hover.

Infinite Marquee Testimonial Wall

Infinite Marquee Testimonial Wall

Original

A three-row marquee wall of review cards scrolling in alternating directions that pause on hover, with a shimmering gradient headline and faded edges.

Grid Testimonial

Grid Testimonial

Original

testimonial grid

Single Large Testimonial

Single Large Testimonial

Original

single large quote

Marquee Testimonial

Marquee Testimonial

Original

testimonial marquee rows

Cards Avatar Testimonial

Cards Avatar Testimonial

Original

testimonial cards with avatars