Testimonial Card
Original · freeA testimonial quote card with avatar and star rating.
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"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
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 quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

