The 4 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Quote card slider"
Library page: https://astro.baysixmedia.com/components/quote-card-slider/
Files: 4 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/QuoteCardSlider.astro =====
---
/**
* QuoteCardSlider
*
* A testimonial slider on a pale grey band with drawn concentric ripples:
* an eyebrow and heading on the left, two round orange arrow buttons on the
* right, then a row of white quote cards. Each card has an italic quote, a
* star row, an orange quote badge sitting in its bottom-right corner and a
* speech-bubble tail pointing down at the person: a ringed photo, a name and
* a role.
*
* Each arrow click moves exactly one card (card width + 30px gap, 418px at
* desktop) and wraps round at the ends, as measured. No autoplay. Three
* cards show at desktop, two on a tablet; a phone stacks them all and drops
* the arrows, as the reference does.
*
* Accessible carousel: a region with aria-roledescription="carousel",
* labelled buttons, a polite live announcement only after a button press,
* and every card outside the visible window inert and aria-hidden.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface QuoteCard {
quote: string;
name: string;
role?: string;
/**
* 0–5 stars, and only the rating this person gave. Omit it and the card
* has no star row: a missing rating is never drawn as five stars.
*/
rating?: number;
/** Round photo. `src: ""` renders the initials disc instead. */
image?: { src?: string; alt?: string };
}
export interface QuoteCardSliderProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
cards?: QuoteCard[];
/** Accessible name of the carousel region. Defaults to the heading. */
label?: string;
/** Draw the concentric ripple pattern behind the section. */
pattern?: boolean;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends QuoteCardSliderProps {}
const {
eyebrow = "Our testimonial",
heading = "What Our Clients Say About Us",
headingLevel = "h2",
/* No cards of its own: testimonials come from the caller or not at all. */
cards = [],
label,
pattern = true,
background = "#eef0f2",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const uid = id ?? `qcs-${Math.random().toString(36).slice(2, 9)}`;
const trackId = `${uid}-track`;
const regionLabel = label ?? heading;
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
/* A rating comes from data or not at all: no number, no star row. */
const clampRating = (r: number | undefined) =>
typeof r === "number" && Number.isFinite(r) ? Math.max(0, Math.min(5, Math.round(r))) : null;
/* Concentric ripples, centred below the band: white strokes on the grey. */
const RIPPLES = Array.from({ length: 22 }, (_, k) => 260 + k * 64);
---
{cards.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["qcs", className]} id={id} style={`--qcs-bg: ${background};`} data-qcs-root>
{
pattern && (
<svg class="qcs-pattern" viewBox="0 0 1440 810" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
<g fill="none" stroke="#ffffff" stroke-opacity="0.55">
{RIPPLES.map((r, k) => (
<ellipse cx="880" cy="1060" rx={r * 1.35} ry={r} stroke-width={k % 3 === 0 ? 14 : 6} />
))}
</g>
</svg>
)
}
<div class="qcs-inner">
<div class="qcs-top">
<div class="qcs-title" data-qcs-appear>
{
eyebrow && (
<p class="qcs-eyebrow">
{eyebrow}
<span class="qcs-eyebrow-line" aria-hidden="true" />
</p>
)
}
<Heading class="qcs-heading">{heading}</Heading>
</div>
<div class="qcs-controls" data-qcs-appear>
<button type="button" class="qcs-arrow" aria-label="Previous testimonial" aria-controls={trackId} data-qcs-prev>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M20 12H5M11 6l-6 6 6 6" />
</svg>
</button>
<button type="button" class="qcs-arrow" aria-label="Next testimonial" aria-controls={trackId} data-qcs-next>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M4 12h15M13 6l6 6-6 6" />
</svg>
</button>
</div>
</div>
<div class="qcs-carousel" role="region" aria-roledescription="carousel" aria-label={regionLabel}>
<div class="qcs-viewport" data-qcs-viewport>
<div class="qcs-track" id={trackId} data-qcs-track>
{
cards.map((card, i) => {
const rating = clampRating(card.rating);
const image = { src: "", alt: "", ...(card.image ?? {}) };
return (
<div
class="qcs-slide"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${cards.length}`}
data-qcs-slide
data-qcs-name={card.name}
data-qcs-index={i + 1}
style={`--qcs-o: ${i}; --qcs-i: ${i + 1};`}
>
<figure class="qcs-figure" data-qcs-appear>
<div class="qcs-bubble">
<blockquote class="qcs-quote">
<p>{card.quote}</p>
</blockquote>
<div class="qcs-bubble-foot">
{rating !== null && (
<span class="qcs-stars" role="img" aria-label={`Rated ${rating} out of 5`}>
{Array.from({ length: 5 }, (_, s) => (
<svg class:list={["qcs-star", { "qcs-star--off": s >= rating }]} viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M6 .9l1.5 3.2 3.5.4-2.6 2.4.7 3.5L6 8.6 2.9 10.4l.7-3.5L1 4.5l3.5-.4z" />
</svg>
))}
</span>
)}
<span class="qcs-badge" aria-hidden="true">
<svg viewBox="0 0 32 24" width="32" height="24" focusable="false">
<path d="M7.5 1.5A6 6 0 0 0 1.5 7.5a6 6 0 0 0 6 6c.6 0 1.1 0 1.6-.2-.7 3-2.9 5.4-5.9 6.6l1 2c5.7-1.9 9.3-6.7 9.3-12.9 0-4.4-2.6-7.5-6-7.5ZM24.5 1.5a6 6 0 0 0-6 6 6 6 0 0 0 6 6c.6 0 1.1 0 1.6-.2-.7 3-2.9 5.4-5.9 6.6l1 2c5.7-1.9 9.3-6.7 9.3-12.9 0-4.4-2.6-7.5-6-7.5Z" />
</svg>
</span>
</div>
<span class="qcs-tail" aria-hidden="true" />
</div>
<figcaption class="qcs-person">
<span class="qcs-ring">
{image.src ? (
<img class="qcs-avatar" src={image.src} alt={image.alt} width="148" height="148" loading="lazy" decoding="async" />
) : (
<span class="qcs-avatar qcs-avatar--initials" aria-hidden="true">
{initials(card.name)}
</span>
)}
</span>
<span class="qcs-person-text">
<span class="qcs-name">{card.name}</span>
{card.role && <span class="qcs-role">{card.role}</span>}
</span>
</figcaption>
</figure>
</div>
);
})
}
</div>
</div>
<div class="qcs-sr" aria-live="polite" aria-atomic="true" data-qcs-live></div>
</div>
</div>
</section>
</>)}
<style>
.qcs {
--qcs-navy: var(--tg-navy, #020e28);
--qcs-orange: var(--tg-orange, #fd5523);
--qcs-muted: var(--tg-muted, #61697d);
--qcs-quote-ink: #001f3f;
--qcs-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--qcs-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--qcs-ease: cubic-bezier(0.22, 1, 0.36, 1);
--qcs-gap: 30px;
--qcs-visible: 1;
position: relative;
box-sizing: border-box;
padding: 60px 16px;
background: var(--qcs-bg);
color: var(--qcs-navy);
font-family: var(--qcs-body);
overflow: hidden;
isolation: isolate;
}
.qcs *,
.qcs *::before,
.qcs *::after {
box-sizing: border-box;
}
.qcs-pattern {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
pointer-events: none;
}
.qcs-inner {
display: flex;
flex-direction: column;
gap: 32px;
max-width: 1224px;
margin: 0 auto;
}
.qcs-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.qcs-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 477px;
}
.qcs .qcs-eyebrow {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--qcs-display);
text-transform: uppercase;
color: var(--qcs-orange);
}
.qcs-eyebrow-line {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.qcs-heading {
margin: 0;
font: 600 30px/1.2 var(--qcs-display);
text-transform: capitalize;
color: var(--qcs-navy);
}
/* ---- arrows: filled orange discs, white arrows ---- */
.qcs-controls {
display: none;
gap: 15px;
flex: none;
}
.qcs-arrow {
display: grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0;
padding: 0;
border: 1px solid var(--qcs-orange);
border-radius: 50%;
background: var(--qcs-orange);
color: #fff;
cursor: pointer;
transition:
background-color 0.3s ease-out,
border-color 0.3s ease-out;
}
.qcs-arrow svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.qcs-arrow:hover {
border-color: var(--qcs-navy);
background: var(--qcs-navy);
}
.qcs-arrow:focus-visible {
outline: 2px solid var(--qcs-navy);
outline-offset: 3px;
}
/* ---- the slides: phone = a stacked list ---- */
.qcs-viewport {
position: relative;
}
.qcs-track {
display: flex;
flex-direction: column;
gap: 24px;
}
.qcs-slide {
order: var(--qcs-o);
min-width: 0;
}
/* Clones exist only for the wrap-round and never show in the list. */
.qcs-slide[data-qcs-clone] {
display: none;
}
.qcs-figure {
display: flex;
flex-direction: column;
gap: 20px;
margin: 0;
}
.qcs-bubble {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
border-radius: 20px;
background: #fff;
}
.qcs-quote {
margin: 0;
padding: 24px 24px 0;
}
.qcs .qcs-quote p {
margin: 0;
font: italic 500 15px/1.47 var(--qcs-body);
color: var(--qcs-quote-ink);
}
.qcs-bubble-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 24px;
}
.qcs-stars {
display: inline-flex;
gap: 3px;
}
.qcs-star {
display: block;
fill: var(--qcs-orange);
stroke: var(--qcs-orange);
stroke-width: 0.8;
stroke-linejoin: round;
}
.qcs-star--off {
fill: none;
}
/* Round on three corners, the card's own 20px radius on the fourth, so it
sits flush in the bottom-right corner. */
.qcs-badge {
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50% 50% 20px 50%;
background: var(--qcs-orange);
}
.qcs-badge svg {
display: block;
width: 26px;
height: auto;
fill: none;
stroke: #fff;
stroke-width: 1.4;
}
.qcs-tail {
position: absolute;
top: 100%;
left: 58px;
width: 51px;
height: 25px;
margin-top: -1px;
background: #fff;
clip-path: polygon(0 0, 100% 0, 58% 88%, 50% 100%, 42% 88%);
}
.qcs-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0 0 40px;
}
.qcs-ring {
display: grid;
place-items: center;
flex: none;
width: 64px;
height: 64px;
padding: 4px;
border: 1px dashed var(--qcs-orange);
border-radius: 50%;
}
.qcs-avatar {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.qcs-avatar--initials {
display: grid;
place-items: center;
background: var(--qcs-navy);
color: #fff;
font: 600 16px/1 var(--qcs-display);
}
.qcs-person-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.qcs-name {
font: 500 18px/28px var(--qcs-display);
color: var(--qcs-navy);
}
.qcs-role {
font: 400 14px/1.2 var(--qcs-body);
color: var(--qcs-muted);
}
.qcs-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- tablet and up: a horizontal slider ---- */
@media (min-width: 810px) {
.qcs {
--qcs-visible: 2;
padding: 100px 30px;
}
.qcs-inner {
gap: 20px;
}
.qcs-title {
gap: 24px;
}
.qcs .qcs-eyebrow {
font-size: 18px;
line-height: 12.6px;
}
.qcs-heading {
font-size: 40px;
}
.qcs[data-qcs-ready][data-qcs-slides] .qcs-controls {
display: flex;
}
.qcs-viewport {
overflow: hidden;
}
/* Without the script the row can still be scrolled by hand. */
.qcs:not([data-qcs-ready]) .qcs-viewport {
overflow-x: auto;
}
.qcs-track {
flex-direction: row;
align-items: flex-start;
gap: var(--qcs-gap);
will-change: transform;
}
.qcs-slide {
order: 0;
flex: 0 0 calc((100% - (var(--qcs-visible) - 1) * var(--qcs-gap)) / var(--qcs-visible));
}
.qcs-slide[data-qcs-clone] {
display: block;
}
.qcs-quote {
padding: 40px 40px 0;
}
.qcs .qcs-quote p {
font-size: 16px;
line-height: 23.488px;
}
.qcs-bubble-foot {
padding-left: 40px;
}
.qcs-badge {
width: 74px;
height: 74px;
}
.qcs-badge svg {
width: 32px;
}
.qcs-person {
padding-top: 0;
}
.qcs-ring {
width: 80px;
height: 80px;
padding: 5px;
}
}
@media (min-width: 1200px) {
.qcs {
--qcs-visible: 3;
padding: 120px 20px;
}
/* Measured: the arrows end 118px short of the container's right edge. */
.qcs-top {
padding-right: 118px;
}
.qcs-heading {
font-size: 48px;
line-height: 57.6px;
}
}
/* ---- appear on scroll: start pose only once the script is ready ---- */
[data-qcs-appear] {
transition:
opacity 0.8s var(--qcs-ease),
transform 0.8s var(--qcs-ease);
transition-delay: calc(var(--qcs-i, 0) * 0.1s);
}
.qcs[data-qcs-ready]:not([data-in]) [data-qcs-appear] {
opacity: 0;
transform: translateY(40px);
}
@media (prefers-reduced-motion: reduce) {
[data-qcs-appear],
.qcs-arrow {
transition: none;
}
.qcs[data-qcs-ready]:not([data-in]) [data-qcs-appear] {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.qcs-bubble {
border: 1px solid CanvasText;
}
.qcs-arrow {
border-color: ButtonText;
}
}
</style>
<script>
/*
* QuoteCardSlider controller.
*
* The wrap-round works by rotation: the originals are cloned once, and a
* move slides the track one step (card width + gap, read from the DOM),
* then moves the first slide to the end and resets the transform. Prev
* does the same in reverse. So there is never an empty slot at either end
* and never a jump back across the whole list.
*
* Whatever sits outside the visible window is inert and aria-hidden, so
* only one copy of each card is ever exposed. The live region speaks only
* after a button press. No timers besides a transitionend fallback, no
* autoplay, no focus moves; everything on one AbortController, aborted on
* astro:before-swap.
*/
function initQuoteCardSlider(root: HTMLElement) {
if (root.dataset.qcsReady === "") return;
const viewport = root.querySelector<HTMLElement>("[data-qcs-viewport]");
const track = root.querySelector<HTMLElement>("[data-qcs-track]");
const prev = root.querySelector<HTMLButtonElement>("[data-qcs-prev]");
const next = root.querySelector<HTMLButtonElement>("[data-qcs-next]");
const live = root.querySelector<HTMLElement>("[data-qcs-live]");
if (!viewport || !track || !prev || !next || !live) return;
const originals = [...track.querySelectorAll<HTMLElement>("[data-qcs-slide]")];
const total = originals.length;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const sliderMode = window.matchMedia("(min-width: 810px)");
/* Clone the set once (twice for two cards) so the view is never short. */
const clones: HTMLElement[] = [];
if (total >= 2) {
const copies = total >= 3 ? 1 : 2;
for (let c = 0; c < copies; c++) {
originals.forEach((slide) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute("data-qcs-clone", "");
clone.removeAttribute("id");
clone.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
track.append(clone);
clones.push(clone);
});
}
root.setAttribute("data-qcs-slides", String(total));
}
const slides = () => [...track.children] as HTMLElement[];
function step() {
const [a, b] = slides();
if (!a || !b) return 0;
return b.getBoundingClientRect().left - a.getBoundingClientRect().left;
}
function visibleCount() {
const s = step();
if (!s) return 1;
return Math.max(1, Math.round((viewport!.clientWidth + (s - slides()[0].offsetWidth)) / s));
}
/* Only the slides in view are exposed; the rest are inert. */
function syncExposure() {
if (!sliderMode.matches || total < 2) {
slides().forEach((slide) => {
const clone = slide.hasAttribute("data-qcs-clone");
slide.inert = clone;
if (clone) slide.setAttribute("aria-hidden", "true");
else slide.removeAttribute("aria-hidden");
});
return;
}
const n = Math.min(visibleCount(), total);
slides().forEach((slide, i) => {
const shown = i < n;
slide.inert = !shown;
if (shown) slide.removeAttribute("aria-hidden");
else slide.setAttribute("aria-hidden", "true");
});
}
let busy: null | (() => void) = null;
let fallback = 0;
function settle(done: () => void) {
const finish = () => {
if (busy !== finish) return;
busy = null;
window.clearTimeout(fallback);
done();
};
busy = finish;
fallback = window.setTimeout(finish, 900);
}
/* One persistent listener; the cards' own appear transitions bubble
here too, so only the track's transform counts. */
track.addEventListener(
"transitionend",
(event) => {
if (event.target === track && event.propertyName === "transform") busy?.();
},
{ signal },
);
function resetTransform() {
track!.style.transition = "none";
track!.style.transform = "";
void track!.offsetWidth;
track!.style.transition = "";
}
function announce(first: HTMLElement | undefined) {
if (!first) return;
live!.textContent = `Showing testimonial ${first.dataset.qcsIndex} of ${total}: ${first.dataset.qcsName ?? ""}`;
}
function move(dir: 1 | -1) {
if (total < 2) return;
busy?.();
const s = step();
const animate = !reduced.matches && s > 0;
/* The card that will lead the view once this move lands. */
const all = slides();
const target = dir === 1 ? all[1] : all[all.length - 1];
if (dir === 1) {
const done = () => {
track!.append(track!.firstElementChild!);
resetTransform();
syncExposure();
};
if (!animate) {
done();
} else {
track!.style.transition = "transform 0.6s var(--qcs-ease)";
track!.style.transform = `translateX(${-s}px)`;
settle(done);
}
} else {
track!.prepend(track!.lastElementChild!);
if (!animate) {
resetTransform();
syncExposure();
} else {
track!.style.transition = "none";
track!.style.transform = `translateX(${-s}px)`;
void track!.offsetWidth;
track!.style.transition = "transform 0.6s var(--qcs-ease)";
track!.style.transform = "translateX(0px)";
/* Expose the incoming card as it slides in. */
syncExposure();
settle(() => {
resetTransform();
syncExposure();
});
}
}
announce(target);
}
prev.addEventListener("click", () => move(-1), { signal });
next.addEventListener("click", () => move(1), { signal });
sliderMode.addEventListener("change", () => {
busy?.();
syncExposure();
}, { signal });
window.addEventListener("resize", () => syncExposure(), { signal, passive: true });
root.dataset.qcsReady = "";
syncExposure();
let observer: IntersectionObserver | undefined;
if (reduced.matches || !("IntersectionObserver" in window)) {
root.setAttribute("data-in", "");
} else {
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.setAttribute("data-in", "");
observer?.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
}
document.addEventListener(
"astro:before-swap",
() => {
busy?.();
window.clearTimeout(fallback);
observer?.disconnect();
controller.abort();
clones.forEach((clone) => clone.remove());
originals.forEach((slide) => {
track.append(slide);
slide.inert = false;
slide.removeAttribute("aria-hidden");
});
resetTransform();
live.textContent = "";
root.removeAttribute("data-in");
root.removeAttribute("data-qcs-slides");
delete root.dataset.qcsReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-qcs-root]").forEach(initQuoteCardSlider);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/QuoteCardSlider.astro =====
===== FILE (supporting): src/components/QuoteCardSlider.md =====
# Quote card slider
A testimonial slider on a pale grey band with drawn concentric ripples. An
eyebrow and heading on the left, two round orange arrow buttons on the right,
then a row of white speech-bubble cards: italic quote, star row, an orange
quote badge in the bottom-right corner, and a tail pointing down at the
person — a ringed photo, name and role.
Catalog page: `/components/quote-card-slider`
## Files required
**`QuoteCardSlider.astro`** — one file, the whole component. No imports, no
packages. The ripples are inline SVG. One inline `<script>` for the slider and
the appear-on-scroll.
`QuoteCardSlider.md` — this file. Documentation, not required to run.
`public/quote-card-slider/client-1..3.webp` — the catalog demo's avatars
(148 × 148). The component has no default cards, so it needs none of them;
pass your own `image` per card.
## Basic usage
```astro
---
import QuoteCardSlider from "../components/QuoteCardSlider.astro";
---
<QuoteCardSlider
cards={[
{ quote: "“Every pickup window, from week one.”", name: "Teresa Alvarez",
role: "Freight Coordinator", rating: 4, image: { src: "/img/teresa.webp", alt: "" } },
// …
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Our testimonial"` | Orange uppercase label with a 40px line after. `""` drops it. |
| `heading` | `string?` | `"What Our Clients Say About Us"` | Section heading. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `cards` | `QuoteCard[]?` | `[]` | `{ quote, name, role?, rating? (0–5; omit it and the card has no stars — never a default five), image?: { src?, alt? } }`. With no cards the component renders nothing. The catalog demo passes its cards explicitly. |
| `label` | `string?` | the heading | Accessible name of the carousel region. |
| `pattern` | `boolean?` | `true` | The drawn ripples. |
| `background` | `string?` | `#eef0f2` | Band colour under the ripples. |
| `fontStylesheet` | `string?` | Outfit + DM Sans (Google Fonts) | `""` to skip loading. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Root id; also seeds the track id. |
`image: { src: "" }` draws the person's initials on a navy disc.
## Behaviour
- **Arrows** (as measured): each click moves the row exactly one card — card
width + 30px gap, 418px at desktop — with a 0.6s ease-out slide, and wraps
round at both ends. The script clones the set once and, after each slide,
moves the first card to the end (or the last to the front for Prev), so
there is never a gap or a long rewind. A click during a slide finishes the
current one first.
- **Arrow hover:** orange → navy (0.3s). See Approximations.
- **No autoplay, no drag or swipe**, as measured.
- **Appear on scroll** (once, at 15% in view): the title, the arrows and each
card rise from `translateY(40px)`, opacity 0 → 1, 0.8s, cards staggered 0.1s.
- **Reduced motion:** cards swap with no slide; no appear.
## Layout
- **≥ 1200px:** three 388px cards visible in a 1224px container; card padding
40px 40px 0, quote DM Sans 16/23.5 italic 500 in `#001f3f`; 12px stars; 74px
badge flush in the bottom-right corner (round on three corners, the card's
20px radius on the fourth); a 51 × 25 tail 58px from the left; 80px dashed
orange ring around a 70px photo; name Outfit 18 / 500, role DM Sans 14 muted.
Arrows 56px, 15px apart.
- **810–1199px:** two cards visible; the step is re-read from the DOM.
- **< 810px:** the reference drops the arrows and stacks all three cards;
so does this. Clones are hidden and the original order is kept.
## Accessibility
- The row is `role="region"` with `aria-roledescription="carousel"`, named by
the heading (or `label`). Each card is `role="group"`,
`aria-roledescription="slide"`, `aria-label="n of N"`.
- Buttons are real `<button>`s labelled "Previous testimonial" / "Next
testimonial", with `aria-controls` pointing at the track, 56px targets and a
visible navy focus ring.
- Every card outside the visible window — including every clone — is `inert`
and `aria-hidden`, so exactly one copy of each card is exposed and nothing
off-screen is reachable.
- A polite, atomic live region says "Showing testimonial 2 of 3: Nadia
Brooks" after a button press only; it stays silent on load.
- Stars are one `role="img"` labelled "Rated 4 out of 5". Avatars are
decorative (`alt=""`) because the name sits beside them.
- Nothing moves focus or scrolls the page. Without JavaScript the row is
horizontally scrollable from 810px and the arrows stay hidden.
## Images and credits
| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/quote-card-slider/client-1.webp` | Avatar 1 | 148 × 148 | Photo: Unsplash SJvDxw0azqw, Unsplash License |
| `public/quote-card-slider/client-2.webp` | Avatar 2 | 148 × 148 | Photo: Unsplash mpDV4xaFP8c, Unsplash License |
| `public/quote-card-slider/client-3.webp` | Avatar 3 | 148 × 148 | Photo: Unsplash bbOOTiq-EPA, Unsplash License |
Stock portraits standing in for customers in the catalog demo; names and
quotes there are invented for the fictional Northpass Logistics and live in
the registry, not the component. Use real, permissioned testimonials live.
## Approximations
- **Arrow style.** The brief described orange-outline buttons; the reference
screenshot shows filled orange discs with white arrows, so the default is
filled. Their hover wasn't in the measured table — navy is the house
primary-button hover, used here as a stand-in.
- **Arrow position.** Measured 118px short of the container's right edge at
desktop; reproduced as `padding-right: 118px` on the header row from 1200px.
- **Slide timing** 0.6s ease-out is not measured; the one-card step and wrap
are.
- **Badge, tail, stars and quote mark** are original SVG/CSS shapes drawn to
the measured boxes (74 × 74, 51 × 26, 12 × 12), not the reference's files.
- **Ripples** are an original inline SVG of concentric white ellipses on
`#eef0f2`, drawn to resemble the reference's background image.
- **Tablet (810–1199)** was not captured; two visible cards is a judgement
call. Tablet was not visually checked in a browser.
===== END src/components/QuoteCardSlider.md =====
===== FILE (required): public/quote-card-slider/client-1.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-1.webp and save it to public/quote-card-slider/client-1.webp]
===== FILE (required): public/quote-card-slider/client-2.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-2.webp and save it to public/quote-card-slider/client-2.webp]
===== FILE (required): public/quote-card-slider/client-3.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-3.webp and save it to public/quote-card-slider/client-3.webp]
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.