Testimonial Carousel
Scroll-snap testimonial carousel with prev/next buttons, star ratings and no autoplay.
Loved by builders worldwide
โWe rebuilt our marketing site in two days. The sections are gorgeous and Lighthouse went straight to 100.โ
AGAlicia GรณmezHead of Growth, Northwind โThe SVG charts replaced a 200 kB library in our dashboard. Same features, zero runtime.โ
MLMarcus LeeStaff Engineer, Lumen โFinally a component kit that takes accessibility seriously. Our audit came back clean.โ
PNPriya NatarajanAccessibility Lead, Civic โCopy, paste, tweak tokens, done. It feels like a design system we built ourselves.โ
TBTom BeckerFounder, Driftly โOur agency ships every client landing page on top of this kit now.โ
SRSofรญa RuizCreative Director, Studio Alba
Usage
Copy and paste this code into your React component:
import { TestimonialCarousel } from 'react-component-kit-ui/sections';
<TestimonialCarousel items={[
{ quote: 'Shipped our site in two days.', name: 'Alicia Gรณmez', role: 'Northwind', rating: 5 },
{ quote: 'Zero-JS charts are magic.', name: 'Marcus Lee', role: 'Lumen', rating: 5 },
]} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/sections/TestimonialCarousel.tsx + TestimonialCarousel.css:
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import './TestimonialCarousel.css';
/** "a: b; --c: d" โ style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
interface Item { quote: string; name: string; role: string; rating?: number }
/**
* TestimonialCarousel โ Scroll-snap testimonial carousel with previous/next
* buttons, keyboard support and no autoplay (respects user control).
*
* @param {string} title - Section heading.
* @param {Array<{quote: string, name: string, role: string, rating?: number}>} items - Testimonials.
* @param {string} variant - Card style (glass|solid|quote).
* @param {string} size - Vertical rhythm (sm|md|lg).
* @param {string} className - Extra CSS class.
*/
export interface TestimonialCarouselProps {
title?: string;
items?: Item[];
variant?: 'glass' | 'solid' | 'quote';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function TestimonialCarousel({
title = 'Loved by builders worldwide',
items = [
{ quote: 'We rebuilt our marketing site in two days. The sections are gorgeous and Lighthouse went straight to 100.', name: 'Alicia Gรณmez', role: 'Head of Growth, Northwind', rating: 5 },
{ quote: 'The SVG charts replaced a 200 kB library in our dashboard. Same features, zero runtime.', name: 'Marcus Lee', role: 'Staff Engineer, Lumen', rating: 5 },
{ quote: 'Finally a component kit that takes accessibility seriously. Our audit came back clean.', name: 'Priya Natarajan', role: 'Accessibility Lead, Civic', rating: 5 },
{ quote: 'Copy, paste, tweak tokens, done. It feels like a design system we built ourselves.', name: 'Tom Becker', role: 'Founder, Driftly', rating: 4 },
{ quote: 'Our agency ships every client landing page on top of this kit now.', name: 'Sofรญa Ruiz', role: 'Creative Director, Studio Alba', rating: 5 },
],
variant = 'glass',
size = 'md',
className = '',
}: TestimonialCarouselProps) {
const id = `tc-${Math.random().toString(36).slice(2, 8)}`;
const initials = (n: string) => n.split(' ').map((p) => p[0]).slice(0, 2).join('');
const hues = ['var(--chart-1, #3987e5)', 'var(--chart-2, #d95926)', 'var(--chart-3, #199e70)', 'var(--chart-5, #d55181)', 'var(--chart-7, #9085e9)'];
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLElement>('[data-tc]').forEach((root) => {
const track = root.querySelector<HTMLElement>('.tc__track');
const btns = root.querySelectorAll<HTMLButtonElement>('.tc__btn');
if (!track) return;
const update = () => {
const max = track.scrollWidth - track.clientWidth - 2;
btns[0].disabled = track.scrollLeft <= 2;
btns[1].disabled = track.scrollLeft >= max;
};
const step = () => (track.querySelector<HTMLElement>('.tc__slide')?.offsetWidth ?? 300) + 20;
btns.forEach((b) => b.addEventListener('click', () => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
track.scrollBy({ left: Number(b.dataset.dir) * step(), behavior: reduce ? 'auto' : 'smooth' });
}));
track.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
update();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="cq-testimonial-carousel-cq" data-rk-a626d8="">
<section className={`tc tc--${variant} tc--${size} ${className}`} aria-labelledby={id} aria-roledescription="carousel" data-tc data-rk-a626d8="">
<div className="tc__inner" data-rk-a626d8="">
<header className="tc__head" data-rk-a626d8="">
<h2 id={id} className="tc__title" data-rk-a626d8="">{title}</h2>
<div className="tc__controls" data-rk-a626d8="">
<button type="button" className="tc__btn" data-dir="-1" aria-label="Previous testimonials" data-rk-a626d8="">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" data-rk-a626d8=""><path d="M15 18l-6-6 6-6" data-rk-a626d8="" /></svg>
</button>
<button type="button" className="tc__btn" data-dir="1" aria-label="Next testimonials" data-rk-a626d8="">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" data-rk-a626d8=""><path d="M9 18l6-6-6-6" data-rk-a626d8="" /></svg>
</button>
</div>
</header>
<ul className="tc__track" tabIndex={0} aria-label="Testimonials, scroll horizontally" data-rk-a626d8="">
{items.map((t, i) => (
<li key={i} className="tc__slide" aria-roledescription="slide" aria-label={`${i + 1} of ${items.length}`} data-rk-a626d8="">
<figure className="tc__card" data-rk-a626d8="">
{t.rating && (
<div className="tc__stars" role="img" aria-label={`${t.rating} out of 5 stars`} data-rk-a626d8="">
{Array.from({ length: 5 }, (_, s) => (
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" className={(t.rating ?? 0) > s ? 'is-on' : ''} data-rk-a626d8=""><path d="m12 2 3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z" fill="currentColor" data-rk-a626d8="" /></svg>
))}
</div>
)}
<blockquote className="tc__quote" data-rk-a626d8=""><p data-rk-a626d8="">โ{t.quote}โ</p></blockquote>
<figcaption className="tc__author" data-rk-a626d8="">
<span className="tc__avatar" style={st(`background:${hues[i % hues.length]}`)} aria-hidden="true" data-rk-a626d8="">{initials(t.name)}</span>
<span data-rk-a626d8=""><strong data-rk-a626d8="">{t.name}</strong><span className="tc__role" data-rk-a626d8="">{t.role}</span></span>
</figcaption>
</figure>
</li>
))}
</ul>
</div>
</section>
</div>
</>
);
}
export default TestimonialCarousel;/* TestimonialCarousel โ ported from astro-component-kit (sections/TestimonialCarousel.astro) */
.cq-testimonial-carousel-cq:where([data-rk-a626d8]) { container-type: inline-size; }
.tc:where([data-rk-a626d8]) {
--tc-py: 5.5rem;
padding: var(--tc-py) var(--sp-6, 1.5rem);
background: var(--c-bg, #050505);
overflow: hidden;
}
.tc--sm:where([data-rk-a626d8]) { --tc-py: 3.5rem; }
.tc--lg:where([data-rk-a626d8]) { --tc-py: 7.5rem; }
.tc__inner:where([data-rk-a626d8]) { max-width: 1180px; margin: 0 auto; }
.tc__head:where([data-rk-a626d8]) { display: flex; justify-content: space-between; align-items: flex-end; gap: 1.5rem; margin-bottom: 2rem; }
.tc__title:where([data-rk-a626d8]) { margin: 0; font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 800; letter-spacing: -0.03em; color: var(--c-text-1, #fff); max-width: 560px; }
.tc__controls:where([data-rk-a626d8]) { display: flex; gap: 0.5rem; flex-shrink: 0; }
.tc__btn:where([data-rk-a626d8]) {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.14);
background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
color: var(--c-text-1, #fff);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.tc__btn:hover:not(:disabled):where([data-rk-a626d8]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.08); border-color: rgb(var(--ink-rgb, 255 255 255) / 0.3); }
.tc__btn:disabled:where([data-rk-a626d8]) { opacity: 0.35; cursor: not-allowed; }
.tc__btn:focus-visible:where([data-rk-a626d8]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.tc__track:where([data-rk-a626d8]) {
list-style: none;
margin: 0;
padding: 0.25rem 0 1rem;
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(380px, 85%);
gap: 20px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.tc__track:where([data-rk-a626d8])::-webkit-scrollbar { display: none; }
.tc__track:focus-visible:where([data-rk-a626d8]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 4px; border-radius: 12px; }
.tc__slide:where([data-rk-a626d8]) { scroll-snap-align: start; }
.tc__card:where([data-rk-a626d8]) {
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.75rem;
border-radius: var(--r-lg, 20px);
box-sizing: border-box;
}
.tc--glass:where([data-rk-a626d8]) .tc__card:where([data-rk-a626d8]) { background: linear-gradient(160deg, rgb(var(--ink-rgb, 255 255 255) / 0.05), rgb(var(--ink-rgb, 255 255 255) / 0.015)); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07)); }
.tc--solid:where([data-rk-a626d8]) .tc__card:where([data-rk-a626d8]) { background: var(--c-bg-2, #111); border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.05); }
.tc--quote:where([data-rk-a626d8]) .tc__card:where([data-rk-a626d8]) { border-left: 2px solid var(--c-primary-light, #d4a017); border-radius: 0; padding-left: 1.5rem; }
.tc__stars:where([data-rk-a626d8]) { display: flex; gap: 2px; color: rgb(var(--ink-rgb, 255 255 255) / 0.15); }
.tc__stars:where([data-rk-a626d8]) .is-on:where([data-rk-a626d8]) { color: var(--c-primary-light, #d4a017); }
.tc__quote:where([data-rk-a626d8]) { margin: 0; flex: 1; }
.tc__quote:where([data-rk-a626d8]) p:where([data-rk-a626d8]) { margin: 0; font-size: 1.0625rem; line-height: 1.6; color: var(--c-text-1, #fff); }
.tc__author:where([data-rk-a626d8]) { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
.tc__author:where([data-rk-a626d8]) strong:where([data-rk-a626d8]) { display: block; color: var(--c-text-1, #fff); font-weight: 650; }
.tc__role:where([data-rk-a626d8]) { display: block; color: var(--c-text-2, #a3a3a3); font-size: 0.8125rem; }
.tc__avatar:where([data-rk-a626d8]) { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8125rem; flex-shrink: 0; }
@container (max-width: 560px) {
.tc__head:where([data-rk-a626d8]) { flex-direction: column; align-items: flex-start; }
}