Skip to content
Blocks

Testimonial Carousel

Sliding customer stories with touch gestures, direct pagination, and reduced-motion support.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/testimonial-carousel.json

Use it

Scroll horizontally for long lines
"use client";
import {TestimonialCarousel} from '@/components/jez-ui/blocks/testimonial-carousel';

export default function Example(){

return <><TestimonialCarousel/></>;
}

Props & types

Scroll horizontally for long lines
export type CustomerStory = {
  quote: string;
  name: string;
  role: string;
  company: string;
  color: string;
  href?: string;
};

{
  items?: CustomerStory[];
  className?: string;
}

Source

View 2 source files

registry/blocks/testimonial-carousel.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react";
import { cn } from "../ui/utils";
export type CustomerStory = {
  quote: string;
  name: string;
  role: string;
  company: string;
  color: string;
  href?: string;
};
const stories: CustomerStory[] = [
  {
    quote: "The best part? Getting back to the work we actually love.",
    name: "Rowan Ellis",
    role: "Design director",
    company: "FIELDWORK",
    color: "#dce4ce",
  },
  {
    quote:
      "One shared place for all the small things that make a big difference.",
    name: "Jamie Chen",
    role: "Studio founder",
    company: "COMMON",
    color: "#e8d8cb",
  },
  {
    quote: "We found our rhythm. The tools finally got out of the way.",
    name: "Alex Morgan",
    role: "Creative lead",
    company: "FREQUENCY",
    color: "#d8dced",
  },
];
export function TestimonialCarousel({
  items = stories,
  className,
}: {
  items?: CustomerStory[];
  className?: string;
}) {
  const [index, setIndex] = React.useState(0);
  const start = React.useRef<number | null>(null);
  const active = Math.min(index, Math.max(0, items.length - 1));
  const move = (delta: number) =>
    setIndex((active + delta + items.length) % items.length);
  if (!items.length) return null;
  return (
    <section
      aria-roledescription="carousel"
      aria-label="Customer stories"
      className={cn(
        "overflow-hidden rounded-xl border border-border bg-background",
        className,
      )}
    >
      <div className="flex items-center justify-between border-b border-border px-6 py-5">
        <p className="text-xs font-medium uppercase tracking-widest">
          In good company
        </p>
        <span className="text-xs text-muted-foreground">
          {items === stories
            ? "Illustrative customer stories"
            : "Customer stories"}
        </span>
      </div>
      <div
        className="overflow-hidden touch-pan-y"
        onTouchStart={(e) => {
          start.current = e.touches[0].clientX;
        }}
        onTouchEnd={(e) => {
          if (start.current !== null) {
            const d = start.current - e.changedTouches[0].clientX;
            if (Math.abs(d) > 45) move(d > 0 ? 1 : -1);
          }
          start.current = null;
        }}
      >
        <div
          className="flex transition-transform duration-500 ease-[cubic-bezier(.22,1,.36,1)] motion-reduce:transition-none"
          style={{ transform: `translateX(-${active * 100}%)` }}
        >
          {items.map((item, i) => (
            <article
              key={item.name}
              aria-hidden={i !== active}
              className="grid w-full shrink-0 md:grid-cols-[.7fr_1.3fr]"
            >
              <div
                style={{ background: item.color }}
                className="relative flex min-h-44 items-center justify-center overflow-hidden p-8 text-[#20271f] md:min-h-80"
              >
                <div
                  aria-hidden
                  className="absolute size-52 rounded-full border-[28px] border-current opacity-10"
                />
                <span className="relative font-display text-2xl font-bold tracking-tighter">
                  {item.company}
                </span>
              </div>
              <div className="flex flex-col justify-between gap-8 p-7 md:p-10">
                <blockquote className="font-display text-2xl leading-snug tracking-tight md:text-3xl">
                  “{item.quote}”
                </blockquote>
                <div className="flex items-end justify-between gap-4">
                  <div>
                    <p className="text-sm font-medium">{item.name}</p>
                    <p className="mt-1 text-xs text-muted-foreground">
                      {item.role}
                    </p>
                  </div>
                  {item.href && (
                    <a
                      tabIndex={i === active ? 0 : -1}
                      href={item.href}
                      aria-label={`Read ${item.company} story`}
                    >
                      <ArrowUpRight size={20} />
                    </a>
                  )}
                </div>
              </div>
            </article>
          ))}
        </div>
      </div>
      <div className="flex items-center justify-between border-t border-border px-6 py-4">
        <div className="flex gap-1">
          {items.map((s, i) => (
            <button
              key={s.name}
              aria-label={`Go to story ${i + 1}`}
              aria-current={i === active ? "true" : undefined}
              onClick={() => setIndex(i)}
              className="grid size-8 place-items-center"
            >
              <span
                className={cn(
                  "h-1 rounded-full transition-all",
                  i === active ? "w-6 bg-foreground" : "w-2 bg-border",
                )}
              />
            </button>
          ))}
        </div>
        <span className="sr-only" aria-live="polite">
          Story {active + 1} of {items.length}
        </span>
        <div className="flex gap-2">
          <button
            aria-label="Previous quote"
            onClick={() => move(-1)}
            className="grid size-10 place-items-center rounded-full border border-border hover:bg-muted"
          >
            <ArrowLeft size={17} />
          </button>
          <button
            aria-label="Next quote"
            onClick={() => move(1)}
            className="grid size-10 place-items-center rounded-full border border-border hover:bg-muted"
          >
            <ArrowRight size={17} />
          </button>
        </div>
      </div>
    </section>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →