Skip to content
All Taste components

Testimonials

Testimonials — avatar carousel

A sideways row of quote cards led by large avatars, with previous and next arrows and position dots.

carousel row · arrows · dots · peek of the next card

Loved by people who live by their calendar

Stylists, clinics and studios that take bookings with Slotwise.

“Booking used to happen in my DMs at midnight. Now clients pick a time themselves and I wake up to a full calendar.”

Lucía FerrerHair stylist

“No-shows dropped once reminders started going out the day before. It paid for itself in the first week.”

Ben OkoroPhysio, Okoro Clinic

“Three therapists, one calendar, and nobody double-booked since we switched.”

Anna KowalskiManager, Still Water Spa

“The deposit at booking changed everything for our weekend slots.”

Ravi ShahBarber
"use client";
import { useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";

const stories = [
  { quote: "Booking used to happen in my DMs at midnight. Now clients pick a time themselves and I wake up to a full calendar.", name: "Lucía Ferrer", role: "Hair stylist" },
  { quote: "No-shows dropped once reminders started going out the day before. It paid for itself in the first week.", name: "Ben Okoro", role: "Physio, 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" },
];

const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2);

export function TestimonialsCarousel() {
  const [index, setIndex] = useState(0);
  const track = useRef<HTMLDivElement>(null);

  const go = (next: number) => {
    const i = Math.max(0, Math.min(stories.length - 1, next));
    setIndex(i);
    const card = track.current?.children[i] as HTMLElement | undefined;
    card?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" });
  };

  return (
    <section aria-labelledby="carousel-title" aria-roledescription="carousel" className="bg-bg px-5 py-16 font-sans sm:px-10 sm:py-20">
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-wrap items-end justify-between gap-5">
          <div className="max-w-xl">
            <h2 id="carousel-title" className="text-balance text-3xl font-semibold tracking-tight text-fg sm:text-4xl">Loved by people who live by their calendar</h2>
            <p className="mt-4 text-base text-fg-muted sm:text-lg">Stylists, clinics and studios that take bookings with Slotwise.</p>
          </div>
          <div className="flex gap-2">
            <button type="button" onClick={() => go(index - 1)} disabled={index === 0} aria-label="Previous story"
              className="grid size-10 place-items-center rounded-full border border-border-strong bg-bg text-fg transition hover:bg-surface disabled:opacity-40">
              <ChevronLeft className="size-4" aria-hidden />
            </button>
            <button type="button" onClick={() => go(index + 1)} disabled={index === stories.length - 1} aria-label="Next story"
              className="grid size-10 place-items-center rounded-full border border-border-strong bg-bg text-fg transition hover:bg-surface disabled:opacity-40">
              <ChevronRight className="size-4" aria-hidden />
            </button>
          </div>
        </div>

        <div ref={track} className="mt-10 flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [scrollbar-width:none]">
          {stories.map((s, i) => (
            <figure
              key={s.name}
              aria-roledescription="slide"
              aria-label={i + 1 + " of " + stories.length}
              className="grid w-[86%] max-w-[380px] shrink-0 snap-start content-start gap-4 rounded-xl border border-border bg-bg p-7"
            >
              <span className="grid size-14 place-items-center rounded-full bg-primary-soft text-lg font-medium text-primary-soft-fg" aria-hidden>{initials(s.name)}</span>
              <blockquote className="text-[15px] leading-relaxed text-fg"><p>“{s.quote}”</p></blockquote>
              <figcaption>
                <p className="text-sm font-semibold text-fg">{s.name}</p>
                <p className="text-[13px] text-fg-muted">{s.role}</p>
              </figcaption>
            </figure>
          ))}
        </div>

        <div className="mt-7 flex justify-center gap-1.5">
          {stories.map((s, i) => (
            <button key={s.name} type="button" onClick={() => go(i)} aria-label={"Go to story " + (i + 1)} aria-current={i === index}
              className={cn("h-[7px] rounded-full transition-all", i === index ? "w-[22px] bg-primary" : "w-[7px] bg-border-strong")} />
          ))}
        </div>
      </div>
    </section>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.