Shop Reviews Editorial
Large serif customer quotes, one at a time, with product context, numbered pager and overall score.
reviewstestimonialsecommercecarousel
Live Preview
Customer notes
4.9/5
โ โ โ โ โ โ โ โ โ โ (1284)โThe overshirt has been washed forty times and it only gets softer. It is the first thing I reach for.โ
โPackaging without plastic, a handwritten note, and a mug that feels made for my hands.โ
โThey repaired the strap of my tote for free, two years after I bought it. That is rare.โ
Usage
Copy and paste this code into your React component:
tsx
import { ShopReviewsEditorial } from 'react-component-kit-ui/sections';
<ShopReviewsEditorial score={4.9} count={1284} reviews={[{ quote: 'Softer every wash.', name: 'Inรฉs M.', product: 'Linen Overshirt' }]} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/sections/ShopReviewsEditorial.tsx + ShopReviewsEditorial.css:
ShopReviewsEditorial.tsx
'use client';
import { useEffect, useRef } from 'react';
import ProductRating from '../ecommerce/ProductRating';
import './ShopReviewsEditorial.css';
/** class:list โ className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
interface Review { quote: string; name: string; place?: string; product?: string; rating?: number }
/**
* ShopReviewsEditorial โ Customer voices as one large serif quote at a time, with product
* context, star rating, numbered pager and an overall score.
*
* @param {string} eyebrow - Small label. Default 'Customer notes'.
* @param {number} score - Average rating shown on the side. Default 4.9.
* @param {number} count - Total reviews. Default 1284.
* @param {Array<{quote: string, name: string, place?: string, product?: string, rating?: number}>} reviews - Quotes.
* @param {number} interval - Auto-advance in ms (0 = manual only). Default 7000.
* @param {string} variant - Style (plain|boxed). Default 'plain'.
* @param {string} className - Extra class.
*
* Accessibility: a labelled carousel region; slides are figures with figcaption; auto-advance
* pauses on hover/focus, is off with reduced motion, and pager buttons use aria-current.
*/
export interface ShopReviewsEditorialProps {
eyebrow?: string;
score?: number;
count?: number;
reviews?: Review[];
interval?: number;
variant?: 'plain' | 'boxed';
className?: string;
}
export function ShopReviewsEditorial({
eyebrow = 'Customer notes',
score = 4.9,
count = 1284,
reviews = [
{ quote: 'The overshirt has been washed forty times and it only gets softer. It is the first thing I reach for.', name: 'Inรฉs M.', place: 'Valencia', product: 'Linen Overshirt', rating: 5 },
{ quote: 'Packaging without plastic, a handwritten note, and a mug that feels made for my hands.', name: 'Jonas P.', place: 'Hamburg', product: 'Kiln Mug', rating: 5 },
{ quote: 'They repaired the strap of my tote for free, two years after I bought it. That is rare.', name: 'Amira S.', place: 'Lyon', product: 'Field Tote', rating: 5 },
],
interval = 7000,
variant = 'plain',
className = '',
}: ShopReviewsEditorialProps) {
// 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-reviews]:not([data-bound])').forEach((root) => {
root.dataset.bound = '';
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
const pages = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-page]'));
let index = 0;
let paused = false;
const go = (i: number) => {
index = (i + slides.length) % slides.length;
slides.forEach((s, n) => { s.hidden = n !== index; s.classList.toggle('is-active', n === index); });
pages.forEach((p, n) => { if (n === index) p.setAttribute('aria-current', 'true'); else p.removeAttribute('aria-current'); });
};
pages.forEach((p, i) => p.addEventListener('click', () => go(i)));
root.addEventListener('mouseenter', () => { paused = true; });
root.addEventListener('mouseleave', () => { paused = false; });
root.addEventListener('focusin', () => { paused = true; });
root.addEventListener('focusout', () => { paused = false; });
const ms = Number(root.dataset.interval) || 0;
if (ms > 0 && slides.length > 1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
window.setInterval(() => { if (!paused && !document.hidden) go(index + 1); }, ms);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="sre-cq" data-rk-45af0e="">
<section className={cx(['sre', `sre--${variant}`, className])} aria-roledescription="carousel" aria-label={eyebrow} data-reviews data-interval={interval} data-rk-45af0e="">
<div className="sre__side" data-rk-45af0e="">
<p className="sre__eyebrow" data-rk-45af0e="">{eyebrow}</p>
<p className="sre__score" data-rk-45af0e="">{score.toFixed(1)}<span data-rk-45af0e="">/5</span></p>
<ProductRating value={score} count={count} showValue={false} size="md" />
</div>
<div className="sre__main" data-rk-45af0e="">
<div className="sre__slides" data-rk-45af0e="">
{reviews.map((r, i) => (
<figure key={i} className={cx(['sre__slide', { 'is-active': i === 0 }])} hidden={i !== 0} aria-roledescription="slide" aria-label={`${i + 1} of ${reviews.length}`} data-slide data-rk-45af0e="">
<blockquote className="sre__quote" data-rk-45af0e=""><p data-rk-45af0e="">โ{r.quote}โ</p></blockquote>
<figcaption className="sre__by" data-rk-45af0e="">
<strong data-rk-45af0e="">{r.name}</strong>{r.place && <span data-rk-45af0e="">, {r.place}</span>}
{r.product && <span className="sre__product" data-rk-45af0e="">on {r.product}</span>}
</figcaption>
</figure>
))}
</div>
{reviews.length > 1 && (
<div className="sre__pager" role="group" aria-label="Choose a review" data-rk-45af0e="">
{reviews.map((_, i) => (
<button key={i} type="button" className="sre__page" aria-label={`Review ${i + 1}`} aria-current={i === 0 ? 'true' : undefined} data-page={i} data-rk-45af0e="">{String(i + 1).padStart(2, '0')}</button>
))}
</div>
)}
</div>
</section>
</div>
</>
);
}
export default ShopReviewsEditorial;ShopReviewsEditorial.css
/* ShopReviewsEditorial โ ported from astro-component-kit (sections/ShopReviewsEditorial.astro) */
.sre-cq:where([data-rk-45af0e]) { container-type: inline-size; }
.sre:where([data-rk-45af0e]) {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: clamp(1.5rem, 5vw, 5rem);
padding-block: clamp(2rem, 5vw, 4rem);
border-top: 1px solid var(--c-text-1, #fff);
color: var(--c-text-1, #fff);
}
.sre--boxed:where([data-rk-45af0e]) { padding: clamp(1.5rem, 4vw, 3.5rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: var(--c-bg-1, #0a0a0a); }
.sre__eyebrow:where([data-rk-45af0e]) { margin: 0 0 var(--sp-4, 1rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.sre__score:where([data-rk-45af0e]) { margin: 0 0 var(--sp-2, 0.5rem); font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: 4.5rem; line-height: 1; letter-spacing: -0.04em; }
.sre__score:where([data-rk-45af0e]) span:where([data-rk-45af0e]) { font-size: 1.2rem; color: var(--c-text-2, #a3a3a3); }
.sre__side:where([data-rk-45af0e]) .p-rating { --p-rating-star: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.sre__slides:where([data-rk-45af0e]) { display: grid; min-height: 12rem; }
.sre__slide:where([data-rk-45af0e]) { grid-area: 1 / 1; margin: 0; }
.sre__slide[hidden]:where([data-rk-45af0e]) { display: none; }
.sre__slide.is-active:where([data-rk-45af0e]) { animation: shop-reviews-editorial-sre-in 0.6s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes shop-reviews-editorial-sre-in { from { opacity: 0; transform: translateY(10px); } }
.sre__quote:where([data-rk-45af0e]) { margin: 0; }
.sre__quote:where([data-rk-45af0e]) p:where([data-rk-45af0e]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(1.6rem, 3.4vw, 2.8rem); font-weight: 400; font-style: italic; line-height: 1.2; letter-spacing: -0.02em; text-wrap: balance; }
.sre__by:where([data-rk-45af0e]) { margin-top: var(--sp-5, 1.25rem); font-size: 0.9rem; }
.sre__by:where([data-rk-45af0e]) span:where([data-rk-45af0e]) { color: var(--c-text-2, #a3a3a3); }
.sre__product:where([data-rk-45af0e]) { display: block; margin-top: 2px; font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; }
.sre__pager:where([data-rk-45af0e]) { display: flex; gap: var(--sp-4, 1rem); margin-top: var(--sp-8, 2rem); }
.sre__page:where([data-rk-45af0e]) { min-width: 44px; min-height: 44px; padding: 0; border: 0; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.2)); background: none; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.8rem; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; }
.sre__page[aria-current='true']:where([data-rk-45af0e]) { border-top: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-text-1, #fff); }
.sre__page:focus-visible:where([data-rk-45af0e]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }
@container (max-width: 760px) { .sre:where([data-rk-45af0e]) { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .sre__slide.is-active:where([data-rk-45af0e]) { animation: none; } }