Component library
Back to catalog

Quote card slider

A testimonial slider on a pale band with drawn ripples: heading left, two round orange arrows right, then white speech-bubble quote cards with stars, an orange quote badge and the person beneath. Each click moves one card and wraps round.

testimonialcarouselsliderquoteappear

Open preview in a new tab

Use this component

Three ways to get Quote card slider into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 4 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Quote card slider from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/quote-card-slider/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Quote card slider” from my Astro Component Library:
https://astro.baysixmedia.com/components/quote-card-slider/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
carousel
Installation files
4 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/QuoteCardSlider.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/quote-card-slider/client-1.webp, public/quote-card-slider/client-2.webp, public/quote-card-slider/client-3.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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>