Testimonial Carousel
Sliding customer stories with touch gestures, direct pagination, and reduced-motion support.
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.jsonUse 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 →