Web InnoventixFreeCode

Testimonial Card

Original · free

A testimonial quote card with avatar and star rating.

byWeb InnoventixReact + Tailwind
cardtestimonialcards
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/card-testimonial.json
card-testimonial.tsx
"use client";

import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Testimonial = {
  id: string;
  name: string;
  role: string;
  company: string;
  avatar: string;
  rating: number;
  short: string;
  full: string;
  date: string;
  source: "G2" | "Trustpilot" | "Product Hunt";
};

const TESTIMONIALS: Testimonial[] = [
  {
    id: "t-linear",
    name: "Priya Nair",
    role: "Head of Design",
    company: "Linear",
    avatar: "/img/gallery/g07.webp",
    rating: 5,
    short:
      "We shipped our redesigned billing flow in nine days instead of the quarter we'd budgeted.",
    full:
      "We shipped our redesigned billing flow in nine days instead of the quarter we'd budgeted. The component library was genuinely production-grade out of the box — keyboard focus, reduced-motion, dark mode all handled. My engineers stopped filing accessibility tickets, which has honestly never happened before.",
    date: "May 2026",
    source: "G2",
  },
  {
    id: "t-ramp",
    name: "Marcus Ellison",
    role: "Staff Engineer",
    company: "Ramp",
    avatar: "/img/gallery/g14.webp",
    rating: 5,
    short:
      "The dark-mode contrast ratios were correct on the first pass. That never happens.",
    full:
      "The dark-mode contrast ratios were correct on the first pass. That never happens. I copied three components into our dashboard and they matched our design tokens without a single override. Documentation is terse in the best way — no fluff, just the prop table and a working example.",
    date: "April 2026",
    source: "Product Hunt",
  },
  {
    id: "t-notion",
    name: "Sofia Rossi",
    role: "Product Lead",
    company: "Notion",
    avatar: "/img/gallery/g22.webp",
    rating: 4,
    short:
      "Tactile, fast, and it just feels considered. Our support tickets about the UI dropped noticeably.",
    full:
      "Tactile, fast, and it just feels considered. Our support tickets about the UI dropped noticeably after we rolled it out. I'd love a few more chart primitives, but what's here is polished to a level I usually only see from teams three times our size.",
    date: "June 2026",
    source: "Trustpilot",
  },
];

function Stars({ rating, idPrefix }: { rating: number; idPrefix: string }) {
  return (
    <div
      className="flex items-center gap-0.5"
      role="img"
      aria-label={`Rated ${rating} out of 5 stars`}
    >
      {[1, 2, 3, 4, 5].map((i) => {
        const filled = i <= rating;
        return (
          <svg
            key={`${idPrefix}-star-${i}`}
            viewBox="0 0 20 20"
            aria-hidden="true"
            className={[
              "h-4 w-4",
              filled
                ? "fill-amber-400 text-amber-400 dark:fill-amber-300 dark:text-amber-300"
                : "fill-transparent text-zinc-300 dark:text-zinc-600",
            ].join(" ")}
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinejoin="round"
          >
            <path d="M10 1.6l2.472 5.008 5.528.803-4 3.898.944 5.506L10 14.94l-4.944 2.6.944-5.506-4-3.898 5.528-.803z" />
          </svg>
        );
      })}
    </div>
  );
}

function SourceGlyph({ source }: { source: Testimonial["source"] }) {
  const common = "h-3.5 w-3.5";
  if (source === "G2") {
    return (
      <svg viewBox="0 0 24 24" aria-hidden="true" className={common}>
        <circle cx="12" cy="12" r="11" className="fill-rose-500" />
        <path
          d="M8 8.4a3.6 3.6 0 015.9-.7l-1.5 1.3a1.6 1.6 0 10-.3 2.3l-2.2 3.4H16v1.9H7.6l3.1-4.8A3.6 3.6 0 018 8.4z"
          className="fill-white"
        />
      </svg>
    );
  }
  if (source === "Trustpilot") {
    return (
      <svg viewBox="0 0 24 24" aria-hidden="true" className={common}>
        <path
          d="M12 2l2.9 6.6 7.1.6-5.4 4.7 1.7 7L12 17.9 5.7 20.9l1.7-7L2 9.2l7.1-.6z"
          className="fill-emerald-500"
        />
      </svg>
    );
  }
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={common}>
      <circle cx="12" cy="12" r="11" className="fill-orange-500" />
      <path
        d="M8.4 6.5h4.3a3.4 3.4 0 010 6.8h-2v4.2H8.4zm2.3 4.7h1.9a1.3 1.3 0 000-2.6h-1.9z"
        className="fill-white"
      />
    </svg>
  );
}

function TestimonialCard({
  data,
  featured = false,
}: {
  data: Testimonial;
  featured?: boolean;
}) {
  const reduce = useReducedMotion();
  const rid = useId();
  const [expanded, setExpanded] = useState(false);
  const [helpful, setHelpful] = useState(false);
  const [helpfulCount, setHelpfulCount] = useState(featured ? 128 : 47);
  const [copied, setCopied] = useState(false);

  const longEnough = data.full.length > data.short.length + 8;

  async function copyQuote() {
    try {
      await navigator.clipboard.writeText(
        `"${data.full}" — ${data.name}, ${data.role} at ${data.company}`,
      );
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  }

  function toggleHelpful() {
    setHelpful((prev) => {
      const next = !prev;
      setHelpfulCount((c) => c + (next ? 1 : -1));
      return next;
    });
  }

  return (
    <figure
      className={[
        "group relative flex h-full flex-col overflow-hidden rounded-2xl border p-6 text-left",
        "transition-all duration-300 ease-out",
        "hover:-translate-y-1 hover:shadow-xl hover:shadow-zinc-900/5 dark:hover:shadow-black/40",
        "motion-reduce:transition-none motion-reduce:hover:translate-y-0",
        featured
          ? "border-indigo-200/80 bg-gradient-to-b from-indigo-50/70 to-white dark:border-indigo-500/30 dark:from-indigo-950/40 dark:to-zinc-900"
          : "border-zinc-200/80 bg-white dark:border-zinc-800 dark:bg-zinc-900",
      ].join(" ")}
    >
      <span
        aria-hidden="true"
        className={[
          "ct-shimmer pointer-events-none absolute inset-x-0 top-0 h-px",
          featured
            ? "bg-gradient-to-r from-transparent via-indigo-400/70 to-transparent dark:via-indigo-300/60"
            : "bg-gradient-to-r from-transparent via-zinc-300/70 to-transparent dark:via-zinc-600/70",
        ].join(" ")}
      />

      <div className="mb-4 flex items-start justify-between gap-3">
        <svg
          viewBox="0 0 32 24"
          aria-hidden="true"
          className={[
            "h-7 w-9 shrink-0",
            featured
              ? "fill-indigo-300 dark:fill-indigo-500/60"
              : "fill-zinc-200 dark:fill-zinc-700",
          ].join(" ")}
        >
          <path d="M0 24V13.5C0 6 4.4 1 12 0l1.2 3.6C9 4.9 6.8 7.4 6.5 11H12v13zm18 0V13.5C18 6 22.4 1 30 0l1.2 3.6C27 4.9 24.8 7.4 24.5 11H30v13z" />
        </svg>
        <Stars rating={data.rating} idPrefix={rid} />
      </div>

      <blockquote className="flex-1">
        <p className="text-[15px] leading-relaxed text-zinc-700 dark:text-zinc-200">
          <span className="font-medium text-zinc-900 dark:text-white">
            {data.short}
          </span>{" "}
          <AnimatePresence initial={false}>
            {expanded && (
              <motion.span
                key="rest"
                id={`${rid}-rest`}
                initial={reduce ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={reduce ? { opacity: 1 } : { opacity: 0 }}
                transition={{ duration: 0.25, ease: "easeOut" }}
                className="text-zinc-600 dark:text-zinc-300"
              >
                {data.full.slice(data.short.length).trimStart()}
              </motion.span>
            )}
          </AnimatePresence>
        </p>
      </blockquote>

      {longEnough && (
        <button
          type="button"
          onClick={() => setExpanded((v) => !v)}
          aria-expanded={expanded}
          aria-controls={`${rid}-rest`}
          className="mt-3 inline-flex w-fit items-center gap-1 rounded-md text-sm font-medium text-indigo-600 outline-none transition-colors hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-300 dark:hover:text-indigo-200 dark:focus-visible:ring-offset-zinc-900"
        >
          {expanded ? "Show less" : "Read full review"}
          <svg
            viewBox="0 0 16 16"
            aria-hidden="true"
            className={[
              "h-3.5 w-3.5 transition-transform duration-300 motion-reduce:transition-none",
              expanded ? "rotate-180" : "",
            ].join(" ")}
          >
            <path
              d="M4 6l4 4 4-4"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </button>
      )}

      <figcaption className="mt-5 flex items-center gap-3 border-t border-zinc-100 pt-4 dark:border-zinc-800">
        <span className="relative inline-flex h-11 w-11 shrink-0 overflow-hidden rounded-full ring-2 ring-white dark:ring-zinc-800">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={data.avatar}
            alt={`${data.name}, ${data.role} at ${data.company}`}
            loading="lazy"
            draggable={false}
            className="h-full w-full object-cover"
          />
        </span>
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-1.5">
            <span className="truncate text-sm font-semibold text-zinc-900 dark:text-white">
              {data.name}
            </span>
            <svg
              viewBox="0 0 20 20"
              aria-label="Verified customer"
              role="img"
              className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-400"
            >
              <path
                d="M10 1.5l2.1 1.6 2.6-.2 1 2.4 2.2 1.4-.7 2.5.7 2.5-2.2 1.4-1 2.4-2.6-.2L10 18.5l-2.1-1.6-2.6.2-1-2.4L2.1 13l.7-2.5L2.1 8l2.2-1.4 1-2.4 2.6.2z"
                className="fill-current"
              />
              <path
                d="M6.8 10.2l2.1 2.1 4.3-4.3"
                fill="none"
                stroke="white"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </div>
          <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
            {data.role} at {data.company}
          </p>
        </div>
        <span
          className="inline-flex shrink-0 items-center gap-1 rounded-full border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] font-medium text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
          title={`Reviewed on ${data.source}`}
        >
          <SourceGlyph source={data.source} />
          {data.source}
        </span>
      </figcaption>

      <div className="mt-4 flex items-center justify-between gap-2">
        <button
          type="button"
          onClick={toggleHelpful}
          aria-pressed={helpful}
          className={[
            "inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium outline-none transition-colors",
            "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900",
            helpful
              ? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
              : "border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800",
          ].join(" ")}
        >
          <svg
            viewBox="0 0 20 20"
            aria-hidden="true"
            className={[
              "h-3.5 w-3.5 transition-transform",
              helpful ? "scale-110 motion-reduce:scale-100" : "",
            ].join(" ")}
          >
            <path
              d="M7 9v8H4a1 1 0 01-1-1V10a1 1 0 011-1zm2 0l3-6a2 2 0 012.7-.9c.8.4 1.1 1.3.9 2.1L14.8 8H18a1.6 1.6 0 011.6 2l-1.3 6a1.6 1.6 0 01-1.6 1.2H9z"
              className={helpful ? "fill-current" : "fill-none stroke-current"}
              strokeWidth="1.4"
              strokeLinejoin="round"
            />
          </svg>
          <span>Helpful</span>
          <span
            className={[
              "tabular-nums",
              helpful ? "text-emerald-600 dark:text-emerald-300" : "text-zinc-400 dark:text-zinc-500",
            ].join(" ")}
          >
            {helpfulCount}
          </span>
        </button>

        <button
          type="button"
          onClick={copyQuote}
          aria-label={copied ? "Quote copied to clipboard" : "Copy quote to clipboard"}
          className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-600 outline-none transition-colors hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
        >
          {copied ? (
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5 text-emerald-500">
              <path
                d="M4 10.5l4 4 8-9"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.8"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          ) : (
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5">
              <rect
                x="7" y="7" width="9" height="9" rx="2"
                fill="none" stroke="currentColor" strokeWidth="1.4"
              />
              <path
                d="M4 12.5V5a1 1 0 011-1h7.5"
                fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"
              />
            </svg>
          )}
          <span>{copied ? "Copied" : "Copy"}</span>
        </button>
      </div>
    </figure>
  );
}

export default function CardTestimonial() {
  return (
    <section className="relative w-full bg-zinc-50 px-4 py-20 sm:px-6 dark:bg-zinc-950">
      <style>{`
        @keyframes ct-shimmer-kf {
          0% { opacity: .35; transform: translateX(-30%); }
          50% { opacity: 1; }
          100% { opacity: .35; transform: translateX(30%); }
        }
        .ct-shimmer { animation: ct-shimmer-kf 4.5s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ct-shimmer { animation: none; transform: none; opacity: .6; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <div className="mx-auto mb-12 max-w-2xl text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Rated 4.9/5 from 2,140+ teams
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
            Loved by the teams who ship
          </h2>
          <p className="mt-3 text-base text-zinc-600 dark:text-zinc-400">
            Real reviews from engineers and designers using the library in production.
          </p>
        </div>

        <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
          {TESTIMONIALS.map((t, i) => (
            <TestimonialCard key={t.id} data={t} featured={i === 0} />
          ))}
        </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 →