Testimonial Slider
Give client stories room to speak. A portrait-led testimonial section with clear controls and no automatic cycling.
Real words. Better work.
Customers can find the right bag and see the details that matter. The storefront finally feels as considered as the products.
Browse three example client stories.
'use client';
import { useId, useState } from 'react';
import type { ReactNode } from 'react';
import './testimonial-slider.css';
export interface TestimonialSliderItem {
id: string;
quote: string;
name: string;
role: string;
company?: string;
portrait?: ReactNode;
metric?: { value: string; label: string };
}
export interface TestimonialSliderProps {
items: TestimonialSliderItem[];
title?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
className?: string;
}
/** Client stories that advance only when a visitor asks. Portraits are decorative. */
export function TestimonialSlider({
items,
title = 'Good words. Real partnerships.',
defaultValue,
onValueChange,
className = '',
}: TestimonialSliderProps) {
const instanceId = useId();
const [selectedId, setSelectedId] = useState(defaultValue ?? items[0]?.id);
const foundIndex = items.findIndex((item) => item.id === selectedId);
const index = foundIndex < 0 ? 0 : foundIndex;
const item = items[index];
const titleId = `${instanceId}-title`;
const storyId = `${instanceId}-story`;
function select(nextIndex: number) {
const next = items[(nextIndex + items.length) % items.length];
if (!next || next.id === item?.id) return;
setSelectedId(next.id);
onValueChange?.(next.id);
}
return (
<section
className={`ac-proof ${className}`.trim()}
aria-roledescription="carousel"
aria-labelledby={title ? titleId : undefined}
aria-label={title ? undefined : 'Client stories'}
>
{title && <h3 className="ac-proof-title" id={titleId}>{title}</h3>}
<div className="ac-proof-stage" id={storyId} aria-live="polite" aria-atomic="true">
{item ? (
<figure className="ac-proof-story" key={item.id}>
<div className="ac-proof-portrait" aria-hidden="true">
{item.portrait ?? <span className="ac-proof-initial">{Array.from(item.name.trim())[0] ?? '✳'}</span>}
</div>
<blockquote className="ac-proof-quote"><p>{item.quote}</p></blockquote>
<figcaption className="ac-proof-credit">
<span className="ac-proof-name">{item.name}</span>
<span className="ac-proof-role">{item.role}{item.company ? `, ${item.company}` : ''}</span>
{item.metric && <span className="ac-proof-metric"><strong>{item.metric.value}</strong><span>{item.metric.label}</span></span>}
</figcaption>
</figure>
) : <p className="ac-proof-empty">No client stories yet.</p>}
</div>
{item && (
<div className="ac-proof-controls" role="group" aria-label="Choose a client story">
<div className="ac-proof-paging">
<button className="ac-proof-arrow" type="button" aria-label="Previous client story" aria-controls={storyId} disabled={items.length < 2} onClick={() => select(index - 1)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M19 12H5m6-6-6 6 6 6" /></svg>
</button>
<span className="ac-proof-count" aria-hidden="true">{String(index + 1).padStart(2, '0')}<span>/ {String(items.length).padStart(2, '0')}</span></span>
<button className="ac-proof-arrow" type="button" aria-label="Next client story" aria-controls={storyId} disabled={items.length < 2} onClick={() => select(index + 1)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6" /></svg>
</button>
</div>
<div className="ac-proof-dots">
{items.map((entry, entryIndex) => (
<button
key={entry.id}
type="button"
className="ac-proof-dot"
aria-label={`Show story ${entryIndex + 1} of ${items.length}: ${entry.name}${entry.company ? ` from ${entry.company}` : ''}`}
aria-pressed={entryIndex === index}
aria-controls={storyId}
onClick={() => select(entryIndex)}
><span aria-hidden="true" /></button>
))}
</div>
</div>
)}
</section>
);
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/testimonial-slider.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { TestimonialSlider } from '@/components/anticraft/TestimonialSlider';
export function Example() {
return (
<TestimonialSlider
title="Good words. Real partnerships."
defaultValue="mira"
items={[
{ id: 'mira', quote: 'Our story finally feels like us. The new website makes it easy for people to find their space.', name: 'Mira Patel', role: 'Founder', company: 'KIN', portrait: <div style={{ display: 'grid', placeItems: 'center', height: '100%', background: '#becbb0', fontSize: 80 }}>M</div> },
{ id: 'daniel', quote: 'A complicated product became a clear first impression. Every feature now has a purpose.', name: 'Daniel Park', role: 'Product lead', company: 'Folio' },
{ id: 'sofia', quote: 'The work is considered from the first page to the smallest detail. Each project has room to shine.', name: 'Sofia Reed', role: 'Creative director', company: 'Forma' },
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TestimonialSliderItem[] | Required | Client stories with a unique id, quote, name, and role. Optional company, decorative portrait, and metric fields add context. An empty list shows a quiet placeholder without controls. |
items[].portrait | ReactNode | Name initial | Decorative image or artwork. It is hidden from assistive technology because the adjacent caption identifies the speaker. Keep this content free of links or controls. |
items[].metric | { value: string; label: string } | undefined | An optional supporting result displayed below the speaker details. Use a substantiated value and a clear label for real client work. |
title | string | 'Good words. Real partnerships.' | A visible heading that labels the section. Set an empty string to omit the heading; the section keeps its Client stories accessible label. |
defaultValue | string | First item | The id selected on the initial render. An unknown or removed id falls back to the first available story. |
onValueChange | (id: string) => void | undefined | Called after a visitor chooses a different story. Supply this callback from a Client Component in Next.js. |
className | string | '' | Additional classes. Customize --ac-proof-surface, --ac-proof-ink, --ac-proof-muted, --ac-proof-accent, and --ac-proof-portrait on this element. |
Motion & accessibility
Previous and next buttons wrap through the stories, and labeled selector dots jump directly to a speaker. Each control works with normal Tab navigation and Enter or Space. Only the selected quote is rendered, and a polite live region announces the newly chosen story. A short entrance fade runs on a visitor’s selection; reduced motion removes it. There is no automatic advance, swipe requirement, or global keyboard interception. The component reflows to a compact portrait and caption below the quote when its container is narrow. Example client names, companies, and quotes are fictional.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
Whipsaw’s client-proof section pairs quotations with portraits and professional roles. Anticraft adapts that useful content structure with original artwork, fictional example stories, and independently designed previous, next, and direct-selection controls.
Visit Whipsaw — client storiesThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.