Web InnoventixFreeCode

Stats Testimonial

Original · free

testimonials with trust stats

byWeb InnoventixReact + Tailwind
tstxstatstestimonials
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-stats.json
tstx-stats.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";

type StatItem = {
  value: number;
  decimals?: number;
  suffix: string;
  label: string;
  sub: string;
};

type Testimonial = {
  id: string;
  quote: string;
  name: string;
  role: string;
  company: string;
  initials: string;
  gradient: string;
};

const STATS: StatItem[] = [
  {
    value: 4.9,
    decimals: 1,
    suffix: "/5",
    label: "Average rating",
    sub: "from 3,200+ verified reviews",
  },
  {
    value: 98,
    suffix: "%",
    label: "Would recommend",
    sub: "measured 90 days post-onboarding",
  },
  {
    value: 12,
    suffix: "k+",
    label: "Teams onboarded",
    sub: "across 40 countries",
  },
  {
    value: 2.4,
    decimals: 1,
    suffix: "x",
    label: "Faster to ship",
    sub: "median cycle-time improvement",
  },
];

const TESTIMONIALS: Testimonial[] = [
  {
    id: "priya",
    quote:
      "We cut our release cycle from every two weeks to daily. Cadence gave every squad the same source of truth, so the 'who's blocking whom' standups just stopped happening.",
    name: "Priya Nair",
    role: "VP Engineering",
    company: "Series C fintech",
    initials: "PN",
    gradient: "from-indigo-500 to-violet-500",
  },
  {
    id: "marcus",
    quote:
      "I've rolled out six tools that promised 'visibility.' This is the first one my team actually opens on a Monday. Onboarding took an afternoon, not a quarter.",
    name: "Marcus Feld",
    role: "Head of Operations",
    company: "400-person logistics team",
    initials: "MF",
    gradient: "from-emerald-500 to-sky-500",
  },
  {
    id: "elena",
    quote:
      "The reporting alone paid for the contract. I walked into a board meeting with live numbers instead of a slide deck I'd stitched together at midnight.",
    name: "Elena Sokolova",
    role: "Director of Product",
    company: "D2C marketplace",
    initials: "ES",
    gradient: "from-rose-500 to-amber-500",
  },
  {
    id: "david",
    quote:
      "Security review is usually where tools go to die. Cadence passed our SOC 2 checklist without a single exception, and the audit log saved us during a customer review.",
    name: "David Okafor",
    role: "CTO",
    company: "Healthtech startup",
    initials: "DO",
    gradient: "from-sky-500 to-indigo-500",
  },
  {
    id: "hana",
    quote:
      "We're spread across nine time zones. Async handoffs used to lose a full day. Now work moves while we sleep and nothing falls through the cracks.",
    name: "Hana Kim",
    role: "Chief of Staff",
    company: "Remote-first agency",
    initials: "HK",
    gradient: "from-violet-500 to-rose-500",
  },
];

function StarIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 2.25l2.955 5.99 6.61.96-4.783 4.662 1.129 6.585L12 17.297l-5.911 3.107 1.129-6.585L2.435 9.2l6.61-.96L12 2.25z" />
    </svg>
  );
}

function QuoteGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 48 48"
      fill="currentColor"
      aria-hidden="true"
      className={className}
    >
      <path d="M18 10c-6.075 0-11 4.925-11 11v17h15V23h-8c0-2.761 2.239-5 5-5V10zm23 0c-6.075 0-11 4.925-11 11v17h15V23h-8c0-2.761 2.239-5 5-5V10z" />
    </svg>
  );
}

function ArrowIcon({
  direction,
  className,
}: {
  direction: "left" | "right";
  className?: string;
}) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      {direction === "left" ? (
        <path d="M15 18l-6-6 6-6" />
      ) : (
        <path d="M9 18l6-6-6-6" />
      )}
    </svg>
  );
}

function ShieldCheckIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
      <path d="M9 12l2 2 4-4" />
    </svg>
  );
}

function Stat({
  stat,
  inView,
  reduced,
}: {
  stat: StatItem;
  inView: boolean;
  reduced: boolean;
}) {
  const [display, setDisplay] = useState(reduced ? stat.value : 0);

  useEffect(() => {
    if (!inView) return;
    if (reduced) {
      setDisplay(stat.value);
      return;
    }
    const controls = animate(0, stat.value, {
      duration: 1.6,
      ease: [0.16, 1, 0.3, 1],
      onUpdate: (v) => setDisplay(v),
    });
    return () => controls.stop();
  }, [inView, reduced, stat.value]);

  const formatted = stat.decimals
    ? display.toFixed(stat.decimals)
    : Math.round(display).toString();

  return (
    <div className="relative flex flex-col items-center rounded-2xl border border-slate-200/70 bg-white/60 px-4 py-6 text-center backdrop-blur-sm transition-colors dark:border-white/10 dark:bg-white/5">
      <div className="flex items-baseline justify-center gap-0.5 font-semibold tracking-tight text-slate-900 dark:text-white">
        <span className="text-4xl sm:text-5xl tstxstats-tabnum">
          {formatted}
        </span>
        <span className="text-xl text-indigo-600 dark:text-indigo-400 sm:text-2xl">
          {stat.suffix}
        </span>
      </div>
      <div className="mt-2 text-sm font-medium text-slate-700 dark:text-slate-200">
        {stat.label}
      </div>
      <div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
        {stat.sub}
      </div>
    </div>
  );
}

function Avatar({
  initials,
  gradient,
  size,
}: {
  initials: string;
  gradient: string;
  size: "sm" | "lg";
}) {
  const dim =
    size === "lg" ? "h-14 w-14 text-lg" : "h-11 w-11 text-sm";
  return (
    <span
      aria-hidden="true"
      className={`inline-flex ${dim} shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${gradient} font-semibold text-white shadow-sm ring-2 ring-white/70 dark:ring-white/10`}
    >
      {initials}
    </span>
  );
}

export default function TstxStats() {
  const reduced = !!useReducedMotion();
  const [active, setActive] = useState(0);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const statsRef = useRef<HTMLDivElement>(null);
  const inView = useInView(statsRef, { once: true, margin: "-80px" });

  const current = TESTIMONIALS[active];

  function goTo(index: number) {
    const count = TESTIMONIALS.length;
    setActive(((index % count) + count) % count);
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    const count = TESTIMONIALS.length;
    let next = active;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (active + 1) % count;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (active - 1 + count) % count;
    } else if (event.key === "Home") {
      next = 0;
    } else if (event.key === "End") {
      next = count - 1;
    } else {
      return;
    }
    event.preventDefault();
    setActive(next);
    tabRefs.current[next]?.focus();
  }

  const panelMotion = reduced
    ? { initial: false as const, animate: {}, exit: {} }
    : {
        initial: { opacity: 0, y: 14 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -14 },
      };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-24 dark:bg-slate-950 sm:py-32">
      <style>{`
        @keyframes tstxstats-float {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -22px, 0) scale(1.06); }
        }
        @keyframes tstxstats-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(24px, 14px, 0); }
        }
        .tstxstats-blob-a { animation: tstxstats-float 16s ease-in-out infinite; }
        .tstxstats-blob-b { animation: tstxstats-drift 20s ease-in-out infinite; }
        .tstxstats-tabnum { font-variant-numeric: tabular-nums; }
        @media (prefers-reduced-motion: reduce) {
          .tstxstats-blob-a,
          .tstxstats-blob-b { animation: none; }
        }
      `}</style>

      {/* Decorative background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="tstxstats-blob-a absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-600/20" />
        <div className="tstxstats-blob-b absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/20" />
        <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-slate-300/60 to-transparent dark:via-white/10" />
      </div>

      <div className="relative mx-auto max-w-6xl px-6">
        {/* Header */}
        <motion.div
          initial={reduced ? false : { opacity: 0, y: 16 }}
          whileInView={reduced ? {} : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-60px" }}
          transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
          className="mx-auto max-w-2xl text-center"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 backdrop-blur-sm dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500/70" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Loved by teams that ship
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Don&apos;t take our word for it. Take theirs.
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
            Thousands of teams run their most important work on Cadence.
            Here&apos;s what actually changed after they switched.
          </p>
        </motion.div>

        {/* Trust stats */}
        <div
          ref={statsRef}
          className="mt-14 grid grid-cols-2 gap-4 sm:gap-5 lg:grid-cols-4"
        >
          {STATS.map((stat) => (
            <Stat
              key={stat.label}
              stat={stat}
              inView={inView}
              reduced={reduced}
            />
          ))}
        </div>

        {/* Featured testimonial */}
        <div className="mt-14">
          <div className="relative overflow-hidden rounded-3xl border border-slate-200/80 bg-white/70 p-8 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-white/[0.04] sm:p-12">
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-50/60 via-transparent to-violet-50/40 dark:from-indigo-500/[0.06] dark:to-violet-500/[0.06]"
            />
            <QuoteGlyph className="pointer-events-none absolute right-8 top-8 h-16 w-16 text-indigo-200/70 dark:text-indigo-500/20" />

            <div className="relative">
              <div className="flex items-center gap-3">
                <div className="flex text-amber-400">
                  {Array.from({ length: 5 }).map((_, i) => (
                    <StarIcon key={i} className="h-5 w-5" />
                  ))}
                </div>
                <span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 dark:text-emerald-400">
                  <ShieldCheckIcon className="h-4 w-4" />
                  Verified customer
                </span>
              </div>

              <AnimatePresence mode="wait" initial={false}>
                <motion.blockquote
                  key={current.id}
                  {...panelMotion}
                  transition={{ duration: reduced ? 0 : 0.4, ease: [0.16, 1, 0.3, 1] }}
                  id="tstx-panel"
                  role="tabpanel"
                  aria-labelledby={`tstx-tab-${current.id}`}
                  tabIndex={0}
                  className="mt-6 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
                >
                  <p className="text-balance text-xl font-medium leading-relaxed text-slate-800 dark:text-slate-100 sm:text-2xl">
                    &ldquo;{current.quote}&rdquo;
                  </p>
                  <footer className="mt-7 flex items-center gap-4">
                    <Avatar
                      initials={current.initials}
                      gradient={current.gradient}
                      size="lg"
                    />
                    <div>
                      <div className="font-semibold text-slate-900 dark:text-white">
                        {current.name}
                      </div>
                      <div className="text-sm text-slate-600 dark:text-slate-400">
                        {current.role} &middot; {current.company}
                      </div>
                    </div>
                  </footer>
                </motion.blockquote>
              </AnimatePresence>
            </div>
          </div>

          {/* Controls */}
          <div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
            <div className="order-2 flex items-center gap-2 sm:order-1">
              <button
                type="button"
                onClick={() => goTo(active - 1)}
                aria-label="Previous testimonial"
                className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10"
              >
                <ArrowIcon direction="left" className="h-5 w-5" />
              </button>
              <button
                type="button"
                onClick={() => goTo(active + 1)}
                aria-label="Next testimonial"
                className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10"
              >
                <ArrowIcon direction="right" className="h-5 w-5" />
              </button>
            </div>

            {/* Tabs / people selector */}
            <div
              role="tablist"
              aria-label="Choose a customer testimonial"
              onKeyDown={onKeyDown}
              className="order-1 flex w-full flex-wrap items-center justify-center gap-2 sm:order-2 sm:w-auto sm:justify-end"
            >
              {TESTIMONIALS.map((t, i) => {
                const selected = active === i;
                return (
                  <button
                    key={t.id}
                    ref={(el) => {
                      tabRefs.current[i] = el;
                    }}
                    role="tab"
                    id={`tstx-tab-${t.id}`}
                    aria-selected={selected}
                    aria-controls="tstx-panel"
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setActive(i)}
                    className={`group inline-flex items-center gap-2.5 rounded-full border px-2 py-1.5 pr-3.5 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 ${
                      selected
                        ? "border-indigo-300 bg-indigo-50 dark:border-indigo-500/40 dark:bg-indigo-500/10"
                        : "border-transparent bg-transparent hover:bg-slate-100 dark:hover:bg-white/5"
                    }`}
                  >
                    <Avatar
                      initials={t.initials}
                      gradient={t.gradient}
                      size="sm"
                    />
                    <span className="hidden sm:block">
                      <span
                        className={`block text-sm font-medium ${
                          selected
                            ? "text-slate-900 dark:text-white"
                            : "text-slate-600 dark:text-slate-300"
                        }`}
                      >
                        {t.name}
                      </span>
                      <span className="block text-xs text-slate-500 dark:text-slate-400">
                        {t.role}
                      </span>
                    </span>
                  </button>
                );
              })}
            </div>
          </div>
        </div>
      </div>
    </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