All Taste components
Testimonials
Testimonials — single-quote slider
One quote at a time, centred, with arrows either side, dots and a counter to move between them.
side arrows · dots with active pill · counter · stars · arrows move under the quote on small screens
“No-shows dropped the week reminders started going out. It paid for itself before the first month was over.”
Ben OkoroPhysiotherapist, Okoro Clinic
02 / 05
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight, Star } from "lucide-react";
import { cn } from "@/lib/utils";
const quotes = [
{ quote: "Booking used to happen in my messages at midnight. Now clients pick a time themselves.", name: "Lucía Ferrer", role: "Hair stylist" },
{ quote: "No-shows dropped the week reminders started going out. It paid for itself before the first month was over.", name: "Ben Okoro", role: "Physiotherapist, Okoro Clinic" },
{ quote: "Three therapists, one calendar, and nobody double-booked since we switched.", name: "Anna Kowalski", role: "Manager, Still Water Spa" },
{ quote: "The deposit at booking changed everything for our weekend slots.", name: "Ravi Shah", role: "Barber" },
{ quote: "Parents book the next lesson before they leave the door.", name: "Grace Obi", role: "Piano tutor" },
];
const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2);
const pad = (n: number) => String(n).padStart(2, "0");
function Arrow({ dir, onClick, className }: { dir: "prev" | "next"; onClick: () => void; className?: string }) {
const Icon = dir === "prev" ? ChevronLeft : ChevronRight;
return (
<button type="button" onClick={onClick} aria-label={dir === "prev" ? "Previous quote" : "Next quote"}
className={cn("size-11 shrink-0 place-items-center rounded-full border border-border-strong bg-bg text-fg transition hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}>
<Icon className="size-[18px]" aria-hidden />
</button>
);
}
export function TestimonialSlider() {
const [i, setI] = useState(0);
const go = (to: number) => setI((to + quotes.length) % quotes.length);
const q = quotes[i];
return (
<section aria-roledescription="carousel" aria-label="What clients say" className="bg-surface px-5 py-12 font-sans sm:px-10 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="grid items-center gap-5 md:grid-cols-[auto_minmax(0,1fr)_auto] md:gap-10">
<Arrow dir="prev" onClick={() => go(i - 1)} className="hidden md:grid" />
<figure aria-roledescription="slide" aria-label={i + 1 + " of " + quotes.length} aria-live="polite"
className="grid justify-items-center gap-6 text-center">
<div className="flex gap-0.5 text-warning" role="img" aria-label="5 out of 5 stars">
{[0, 1, 2, 3, 4].map((s) => <Star key={s} className="size-[17px] fill-current" aria-hidden />)}
</div>
<blockquote className="max-w-[30ch] text-balance text-2xl font-medium leading-tight tracking-tight text-fg sm:text-[34px]">
<p>“{q.quote}”</p>
</blockquote>
<figcaption className="grid justify-items-center gap-2.5">
<span className="grid size-[52px] place-items-center rounded-full bg-primary-soft text-[17px] font-medium text-primary-soft-fg" aria-hidden>{initials(q.name)}</span>
<div>
<p className="text-[15px] font-semibold text-fg">{q.name}</p>
<p className="text-[13.5px] text-fg-muted">{q.role}</p>
</div>
</figcaption>
</figure>
<Arrow dir="next" onClick={() => go(i + 1)} className="hidden md:grid" />
</div>
<div className="mt-8 flex items-center justify-center gap-4">
<Arrow dir="prev" onClick={() => go(i - 1)} className="grid md:hidden" />
<div className="flex items-center gap-1">
{quotes.map((_, d) => (
<button key={d} type="button" aria-label={"Show quote " + (d + 1)} aria-current={d === i ? "true" : undefined} onClick={() => go(d)}
className={cn("grid h-5 place-items-center", d === i ? "w-8" : "w-5")}>
<span className={cn("h-[7px] rounded-full transition-all", d === i ? "w-6 bg-primary" : "w-[7px] bg-border-strong hover:bg-fg-subtle")} />
</button>
))}
</div>
<span className="text-[13px] tabular-nums text-fg-subtle"><span className="font-medium text-fg">{pad(i + 1)}</span> / {pad(quotes.length)}</span>
<Arrow dir="next" onClick={() => go(i + 1)} className="grid md:hidden" />
</div>
</div>
</section>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.