Skip to content
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.