Testimonial Carousel
Карусель отзывов со scroll-snap, кнопками вперёд/назад, рейтингом и без автопрокрутки.
testimonialscarouselsliderreviews
Живое превью
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
Использование
Скопируйте и вставьте этот код в ваш React-компонент:
tsx
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 },
]} />Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/sections/TestimonialCarousel.tsx + TestimonialCarousel.css:
TestimonialCarousel.tsx
'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.css
/* 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; }
}