Grid Testimonial
Original · freetestimonial grid
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-grid.json"use client";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Team = "Engineering" | "Product" | "Design" | "Marketing" | "Operations";
type Filter = Team | "All";
type Testimonial = {
id: string;
quote: string;
name: string;
role: string;
org: string;
team: Team;
rating: number;
accent: string;
featured?: boolean;
};
const TESTIMONIALS: Testimonial[] = [
{
id: "t1",
quote:
"Onboarding used to eat a full week per hire. With Northwind's shared workspaces, new engineers ship their first real change on day two. It paid for itself before the trial ran out.",
name: "Priya Raman",
role: "Engineering Lead",
org: "logistics platform",
team: "Engineering",
rating: 5,
accent: "from-indigo-500 to-violet-500",
featured: true,
},
{
id: "t2",
quote:
"I stopped dreading the Monday status meeting. Everything lives on one board, so I walk in already knowing where every project actually stands.",
name: "Marcus Feld",
role: "Product Manager",
org: "B2B fintech",
team: "Product",
rating: 5,
accent: "from-sky-500 to-indigo-500",
},
{
id: "t3",
quote:
"The automated checks flagged a billing regression two hours before launch. That single catch saved us a very awkward all-customer email.",
name: "Dana Osei",
role: "QA Lead",
org: "subscription commerce",
team: "Engineering",
rating: 5,
accent: "from-emerald-500 to-sky-500",
},
{
id: "t4",
quote:
"We folded four separate tools into Northwind and cut our software bill by 38%. Nobody on the team misses the constant tab-switching.",
name: "Lena Vogt",
role: "Operations Director",
org: "healthcare SaaS",
team: "Operations",
rating: 5,
accent: "from-amber-500 to-rose-500",
},
{
id: "t5",
quote:
"As a designer I care about the small things. The keyboard shortcuts and the clean handoff view feel like they were built by people who actually ship.",
name: "Noah Kim",
role: "Senior Product Designer",
org: "mobile studio",
team: "Design",
rating: 5,
accent: "from-violet-500 to-rose-500",
},
{
id: "t6",
quote:
"Our marketing calendar finally matches reality. Campaigns that used to slip by weeks now go out on the exact day we planned them.",
name: "Elena Cruz",
role: "Marketing Lead",
org: "DTC brand",
team: "Marketing",
rating: 5,
accent: "from-rose-500 to-amber-500",
},
{
id: "t7",
quote:
"Migrations terrified me. Their team moved five years of records over a weekend, and Monday morning everything just worked.",
name: "Aisha Bello",
role: "IT Manager",
org: "regional bank",
team: "Engineering",
rating: 4,
accent: "from-sky-500 to-emerald-500",
},
{
id: "t8",
quote:
"Tickets asking 'where is this document' basically disappeared. The team finally trusts one source of truth instead of six chat threads.",
name: "Sam Whitfield",
role: "Head of Support",
org: "edtech",
team: "Operations",
rating: 5,
accent: "from-indigo-500 to-emerald-500",
},
{
id: "t9",
quote:
"Roadmap reviews went from a two-day spreadsheet ritual to a live board my whole leadership team reads on their own. That alone changed how we plan.",
name: "Tomás Ferreira",
role: "VP of Product",
org: "workforce startup",
team: "Product",
rating: 5,
accent: "from-violet-500 to-indigo-500",
},
];
const FILTERS: Filter[] = [
"All",
"Engineering",
"Product",
"Design",
"Marketing",
"Operations",
];
const STATS: { value: string; label: string }[] = [
{ value: "4.9/5", label: "average rating" },
{ value: "1,200+", label: "verified teams" },
{ value: "38%", label: "median tool spend saved" },
{ value: "2 days", label: "to first shipped change" },
];
function initials(name: string): string {
return name
.split(" ")
.map((part) => part.charAt(0))
.slice(0, 2)
.join("")
.toUpperCase();
}
function Stars({ rating }: { rating: number }) {
return (
<div
className="flex items-center gap-0.5"
role="img"
aria-label={`Rated ${rating} out of 5`}
>
{[0, 1, 2, 3, 4].map((i) => (
<svg
key={i}
viewBox="0 0 20 20"
aria-hidden="true"
className={
i < rating
? "h-4 w-4 fill-amber-400"
: "h-4 w-4 fill-slate-300 dark:fill-slate-700"
}
>
<path d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.94 2.6.94-5.5-4-3.9 5.53-.8z" />
</svg>
))}
</div>
);
}
export default function TstxGrid() {
const reduce = useReducedMotion();
const [active, setActive] = useState<Filter>("All");
const visible = useMemo(
() =>
active === "All"
? TESTIMONIALS
: TESTIMONIALS.filter((t) => t.team === active),
[active],
);
return (
<section className="relative w-full overflow-hidden bg-white px-5 py-24 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-white">
<style>{`
@keyframes tstxgrid-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -26px, 0); }
}
@keyframes tstxgrid-float-alt {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, 22px, 0); }
}
.tstxgrid-orb-a { animation: tstxgrid-float 13s ease-in-out infinite; }
.tstxgrid-orb-b { animation: tstxgrid-float-alt 16s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.tstxgrid-orb-a, .tstxgrid-orb-b { animation: none !important; }
}
`}</style>
{/* Decorative background */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div className="tstxgrid-orb-a absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-600/20" />
<div className="tstxgrid-orb-b absolute -right-16 top-1/3 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/20" />
<div className="tstxgrid-orb-a absolute bottom-0 left-1/3 h-64 w-64 rounded-full bg-sky-300/20 blur-3xl dark:bg-sky-700/15" />
</div>
<div className="relative mx-auto max-w-6xl">
{/* Header */}
<div 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.5 py-1.5 text-xs font-semibold uppercase tracking-wider text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Customer stories
</span>
<h2 className="mt-5 text-balance text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Teams don't just try Northwind.{" "}
<span className="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">
They keep it.
</span>
</h2>
<p className="mt-5 text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-400">
Over 1,200 product, engineering, and design teams run their day-to-day
on Northwind. Here is what a few of them told us, unedited.
</p>
</div>
{/* Stats */}
<dl className="mx-auto mt-12 grid max-w-3xl grid-cols-2 gap-px overflow-hidden rounded-2xl border border-slate-200 bg-slate-200 sm:grid-cols-4 dark:border-slate-800 dark:bg-slate-800">
{STATS.map((s) => (
<div
key={s.label}
className="bg-white px-4 py-5 text-center dark:bg-slate-950"
>
<dt className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
{s.value}
</dt>
<dd className="mt-1 text-xs font-medium text-slate-500 dark:text-slate-400">
{s.label}
</dd>
</div>
))}
</dl>
{/* Filters */}
<div
className="mt-12 flex flex-wrap items-center justify-center gap-2"
role="group"
aria-label="Filter testimonials by team"
>
{FILTERS.map((f) => {
const isActive = f === active;
return (
<button
key={f}
type="button"
aria-pressed={isActive}
onClick={() => setActive(f)}
className={
"rounded-full px-4 py-2 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 " +
(isActive
? "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/60 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white")
}
>
{f}
</button>
);
})}
</div>
{/* Grid */}
<motion.div
layout={!reduce}
className="mt-10 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"
>
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((t, i) => (
<motion.article
key={t.id}
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.98 }}
transition={{
duration: reduce ? 0 : 0.42,
delay: reduce ? 0 : Math.min(i * 0.04, 0.28),
ease: [0.22, 1, 0.36, 1],
}}
className={
"group relative flex flex-col rounded-2xl border p-6 transition-colors duration-300 focus-within:ring-2 focus-within:ring-indigo-500 hover:-translate-y-1 " +
(t.featured
? "border-transparent bg-gradient-to-br from-indigo-50 to-violet-50 sm:col-span-2 lg:col-span-2 dark:from-indigo-950/40 dark:to-violet-950/30"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-slate-700")
}
>
{t.featured ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-indigo-300/60 dark:ring-indigo-500/30"
/>
) : null}
<div className="flex items-center justify-between">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-8 w-8 fill-indigo-400/70 dark:fill-indigo-500/60"
>
<path d="M9.6 5.2C6.5 6.7 4.6 9.6 4.6 13.1v5.7h6.1v-6.1H7.9c0-2.1 1-3.7 3-4.6l-1.3-2.9zm9.8 0c-3.1 1.5-5 4.4-5 7.9v5.7h6.1v-6.1h-2.8c0-2.1 1-3.7 3-4.6l-1.3-2.9z" />
</svg>
{t.featured ? (
<span className="rounded-full bg-white/70 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-indigo-600 dark:bg-slate-900/60 dark:text-indigo-300">
Editor's pick
</span>
) : (
<Stars rating={t.rating} />
)}
</div>
<blockquote
className={
"mt-4 flex-1 text-pretty leading-relaxed text-slate-700 dark:text-slate-200 " +
(t.featured ? "text-lg sm:text-xl" : "text-base")
}
>
“{t.quote}”
</blockquote>
{t.featured ? (
<div className="mt-4">
<Stars rating={t.rating} />
</div>
) : null}
<figcaption className="mt-6 flex items-center gap-3">
<span
aria-hidden="true"
className={
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br text-sm font-bold text-white shadow-sm " +
t.accent
}
>
{initials(t.name)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-slate-900 dark:text-white">
{t.name}
</span>
<span className="block truncate text-sm text-slate-500 dark:text-slate-400">
{t.role}, {t.org}
</span>
</span>
</figcaption>
</motion.article>
))}
</AnimatePresence>
</motion.div>
{/* Empty state guard (never triggers with current data, but safe) */}
{visible.length === 0 ? (
<p className="mt-10 text-center text-sm text-slate-500 dark:text-slate-400">
No stories from this team yet.
</p>
) : null}
{/* Footer CTA */}
<div className="mt-14 flex flex-col items-center justify-center gap-4 text-center sm:flex-row">
<a
href="#"
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-950"
>
Read all 1,200 stories
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-4 w-4 fill-current"
>
<path d="M11.3 4.3a1 1 0 011.4 0l5 5a1 1 0 010 1.4l-5 5a1 1 0 01-1.4-1.4L14.6 11H4a1 1 0 110-2h10.6l-3.3-3.3a1 1 0 010-1.4z" />
</svg>
</a>
<span className="text-sm text-slate-500 dark:text-slate-400">
Every review is from a verified, paying Northwind team.
</span>
</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 →
Three Card Testimonial Grid
OriginalA clean three-column grid of testimonial cards, each with a star rating, quote and an initial avatar with name and role.

Single Featured Quote
OriginalA 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
OriginalA pair of infinitely scrolling, pause-on-hover marquees pairing a customer logo wall with compact quote cards, with reduced-motion support.

Masonry Testimonial Wall
OriginalA 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
OriginalA split layout pairing a large aggregate rating score, star row and overlapping avatar stack with two featured review cards.

Auto-Rotating Quote Carousel
OriginalAn 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
OriginalA 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
OriginalA three-row marquee wall of review cards scrolling in alternating directions that pause on hover, with a shimmering gradient headline and faded edges.

Single Large Testimonial
Originalsingle large quote

Marquee Testimonial
Originaltestimonial marquee rows
Cards Avatar Testimonial
Originaltestimonial cards with avatars

Logos Quote Testimonial
Originalquote with a logo strip

