Site
Testimonials
Quote cards with highlighted text on the active card.
$ npx context-ds add testimonialsUsage
Use it for
Customer quotes on the home page and landing pages, three across on desktop.
Avoid
- Star ratings
- Moving or scaling cards on hover
- Bold quotes
- Mixed card layouts in one grid
Dependencies: lucide-react
Example
examples/testimonials.tsx
import { Testimonials, type Testimonial } from "@/components/site/testimonials";// The six the home page shows, in its order.const items: Testimonial[] = [ { id: 23, quote: "Onboarding couldn't be simpler. Self-serve sign-up gives you an API key immediately, the documentation is thorough, and we were integrating within 10 minutes.", highlights: ["we were integrating within 10 minutes"], name: "Nick Khami", role: "Engineering Manager @ Mintlify", href: "https://context.dev/blog/mintlify-turns-github-repos-into-branded-docs-sites-with-brand-dev", }, { id: 1, quote: "As soon as you type in the email, we use Context.dev to collect information about your brand and different logos. Context.dev saves us a lot of time right now.", highlights: ["Context.dev saves us a lot of time right now"], name: "John Acosta", role: "Founder @ UsePropane.ai", href: "https://context.dev/blog/propane-ai-streamlines-onboarding-and-delivers-branded-user-interview-experiences-with-brand-dev", }, { id: 16, quote: "Getting started is very simple. API docs are great and sign-up is self serve, with an API key generated immediately. Took 10 minutes to start integrating.", highlights: ["Getting started is very simple"], name: "Luke Ramsden", role: "CPTO @ Architect (tryarchitect.com)", href: "https://context.dev/blog/architect-enhances-product-onboarding-and-sales-gtm-with-brand-dev", }, { id: 18, quote: "We're seeing much higher activation rates for our free trials and sign-ups because of it. It's made it a lot simpler for customers to get started with DocsBot and to get to that 'aha' value moment very quickly.", highlights: ["much higher activation rates", "'aha' value moment"], name: "Aaron Edwards", role: "Founder @ DocsBot", href: "https://context.dev/blog/docsbot-reduces-onboarding-friction-and-boosts-activation-with-brand-dev", }, { id: 39, quote: "The API integration was seamless and expedient. My team was able to get it set up and testing with production workloads within an hour of hearing what Context.dev was!", highlights: ["testing with production workloads within an hour"], name: "Christopher Price", role: "COO @ Sixtyfour", href: "https://context.dev/customers/sixtyfour-tests-production-scraping-workloads-within-an-hour-with-context-dev", }, { id: 21, quote: "Context.dev offers extensive documentation making the setup super easy. On top of this, there is a dedicated Slack channel in case you have follow-up questions. Overall the API integration process was very smooth.", highlights: ["extensive documentation", "dedicated Slack channel"], name: "Vlad Veselukha", role: "Senior Data Engineer @ Vizzy", href: "https://context.dev/blog/vizzy-beautifies-profiles-and-job-boards-with-brand-dev", },];export function Site() { return ( <div className="flex w-full flex-col gap-10"> <div className="text-center"> <h2 className="text-h2 leading-tight tracking-tight text-fg"> <span className="text-fg-brand">Success</span> stories </h2> <p className="mx-auto mt-4 max-w-md text-body leading-relaxed text-fg-muted">Feedback from the engineers shipping with us every day.</p> </div> <Testimonials items={items} /> </div> );}Source
components/site/testimonials.tsx
"use client";import * as React from "react";import { ArrowUpRight } from "lucide-react";import { cn } from "@/lib/utils";// The home page's "Success stories": quote cards on a hairline, every card the same anatomy (quote on top, who said it// along the bottom) and every row as tall as its tallest card, so the set reads as one. Hovering or focusing a card only// brings its color in (the highlighted phrases turn brand blue, the photo leaves grayscale); nothing moves or resizes.type Testimonial = { id?: string | number; quote: string; /** Phrases inside the quote to emphasize; they turn brand blue on the active card. */ highlights?: string[]; name: string; /** The line under the name, e.g. "Engineering Manager @ Mintlify". */ role: string; /** Photo URL, shown in grayscale until the card is active. */ avatar?: string; /** The case study; the whole card becomes the link. */ href?: string; linkLabel?: string;};function escapeRegExp(text: string) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");}function HighlightedQuote({ text, highlights }: { text: string; highlights?: string[] }) { if (!highlights?.length) return <>{text}</>; const set = new Set(highlights); const parts = text.split(new RegExp(`(${highlights.map(escapeRegExp).join("|")})`, "g")); return <>{parts.map((part, i) => (set.has(part) ? <strong key={i}>{part}</strong> : part))}</>;}function TestimonialCard({ testimonial, active = false, className, ...props }: React.ComponentProps<"figure"> & { testimonial: Testimonial; active?: boolean }) { const { quote, highlights, name, role, avatar, href, linkLabel = "Read the case study" } = testimonial; return ( <figure data-slot="testimonial-card" data-active={active || undefined} className={cn("group relative flex flex-col rounded-card p-6 shadow-card transition-[background-color] duration-300 ease-out hover:bg-subtle/60 motion-reduce:transition-none", className)} {...props} > {href && ( <a href={href} aria-label={`${linkLabel}: ${name}`} title={linkLabel} className="absolute inset-0 z-10 rounded-card outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2"> <span className="absolute top-4 right-4 flex size-8 items-center justify-center rounded-pill text-fg-faint transition-[background-color,color] duration-200 ease-out group-hover:bg-tint group-hover:text-fg-brand motion-reduce:transition-none"> <ArrowUpRight aria-hidden="true" className="size-4" /> </span> </a> )} <blockquote className={cn( "pr-8 text-body leading-relaxed text-fg-nav [&_strong]:font-medium [&_strong]:transition-[color] [&_strong]:duration-300 [&_strong]:ease-out motion-reduce:[&_strong]:transition-none", active ? "[&_strong]:text-fg-brand" : "[&_strong]:text-fg", )} > “ <HighlightedQuote text={quote} highlights={highlights} /> ” </blockquote> <figcaption className="mt-auto flex min-w-0 items-center gap-3 pt-6"> {avatar && ( <span className="size-10 shrink-0 overflow-hidden rounded-card bg-subtle"> <img src={avatar} alt={name} width={40} height={40} className={cn("size-full object-cover transition-[filter] duration-300 ease-out motion-reduce:transition-none", !active && "grayscale")} /> </span> )} <span className="min-w-0"> <span className="block truncate text-body-sm leading-5 font-medium text-fg">{name}</span> <span className="block truncate text-body-sm text-fg-subtle" title={role}> {role} </span> </span> </figcaption> </figure> );}/** The grid: one column, two from md, three from lg. The first card is active until another is hovered or focused. */function Testimonials({ items, className, ...props }: React.ComponentProps<"div"> & { items: Testimonial[] }) { const key = (t: Testimonial, i: number) => String(t.id ?? i); const first = items[0] ? key(items[0], 0) : ""; const [active, setActive] = React.useState(first); return ( <div data-slot="testimonials" className={cn("grid auto-rows-fr gap-4 md:grid-cols-2 lg:grid-cols-3", className)} onMouseLeave={() => setActive(first)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setActive(first); }} {...props} > {items.map((t, i) => ( <TestimonialCard key={key(t, i)} testimonial={t} active={active === key(t, i)} onMouseEnter={() => setActive(key(t, i))} onFocus={() => setActive(key(t, i))} /> ))} </div> );}export { Testimonials, TestimonialCard, type Testimonial };Reference: src/components/sections/home/testimonials.tsx.