Component library
Back to catalog

Numbered testimonial switcher

A light testimonial section: centred eyebrow and heading, a rounded portrait left and a big quote, name and orange role right, switched by "01 ——— 02 03 04" number tabs. The portrait cross-fades and the quote slides in. Phone lists them all.

testimonialtabsquoteportraitappear

Open preview in a new tab

Use this component

Three ways to get Numbered testimonial switcher 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 5 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 Numbered testimonial switcher from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/numbered-testimonial-switcher/

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 5 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: "Numbered testimonial switcher"
Library page: https://astro.baysixmedia.com/components/numbered-testimonial-switcher/
Files: 5 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/NumberedTestimonialSwitcher.astro =====
---
/**
 * NumberedTestimonialSwitcher
 *
 * A light testimonial section: centred eyebrow and heading, then a rounded
 * portrait on the left and a large quote, name and role on the right. Under
 * the quote, numbered tabs "01 ——— 02 03 04" pick the testimonial; the
 * active number carries the orange line after it. Picking one cross-fades
 * the portrait and slides its quote card in from the side (from the right
 * when moving forward, from the left when moving back). No autoplay.
 *
 * Real WAI-ARIA tabs: roving tabindex, Left/Right/Home/End, aria-selected
 * and aria-controls. Below 810px the reference drops the tabs and lists
 * every testimonial, each with its own photo; so does this.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface NumberedTestimonial {
	quote: string;
	name: string;
	role?: string;
	/** Portrait. `src: ""` renders the labelled placeholder instead. */
	image?: { src?: string; alt?: string };
}

export interface NumberedTestimonialSwitcherProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	testimonials?: NumberedTestimonial[];
	/** 1-based testimonial shown first. */
	initial?: number;
	/** Accessible name of the tab row. */
	tabsLabel?: string;
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends NumberedTestimonialSwitcherProps {}

const {
	eyebrow = "Testimonial",
	heading = "What Our Clients Say",
	headingLevel = "h2",
	/* No testimonials of its own: they come from the caller or not at all. */
	testimonials = [],
	initial = 1,
	tabsLabel = "Choose a testimonial",
	background = "#f6f6f6",
	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 ?? `nts-${Math.random().toString(36).slice(2, 9)}`;
const items = testimonials.slice(0, 9);
const active = Math.min(Math.max(1, Math.round(initial)), Math.max(1, items.length)) - 1;
const pad = (n: number) => String(n).padStart(2, "0");
const pos = (i: number) => (i === active ? "active" : i < active ? "before" : "after");

/* Measured slot: 392 × 452 at desktop; the files are 784 × 904. */
const IMAGE_SPEC = "~6:7 portrait · 784 × 904 px";
---

{items.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
	class:list={["nts", className]}
	id={id}
	style={`--nts-bg: ${background};`}
	data-nts-root
>
	<div class="nts-inner">
		<div class="nts-title" data-nts-appear="up">
			{
				eyebrow && (
					<p class="nts-eyebrow">
						<span class="nts-eyebrow-line" aria-hidden="true" />
						{eyebrow}
						<span class="nts-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			<Heading class="nts-heading">{heading}</Heading>
		</div>

		<div class="nts-stage">
			{
				items.map((t, i) => {
					const image = { src: "", alt: "", ...(t.image ?? {}) };
					return (
						<div
							class="nts-panel"
							id={`${uid}-panel-${i + 1}`}
							role="tabpanel"
							aria-labelledby={`${uid}-tab-${i + 1}`}
							tabindex="0"
							data-nts-panel
							data-pos={pos(i)}
						>
							<div class="nts-photo-col" data-nts-appear="left">
								<div class="nts-photo">
									{image.src ? (
										<img
											src={image.src}
											alt={image.alt}
											width="784"
											height="904"
											loading={i === active ? "eager" : "lazy"}
											decoding="async"
										/>
									) : (
										<div class="nts-ph" role="img" aria-label={`Image placeholder: portrait of ${t.name}`}>
											<div class="nts-ph-text" aria-hidden="true">
												<span class="nts-ph-label">Image</span>
												<span class="nts-ph-hint">Portrait of {t.name}</span>
												<span class="nts-ph-spec">{IMAGE_SPEC}</span>
											</div>
										</div>
									)}
								</div>
							</div>
							<div class="nts-copy" data-nts-appear="right">
								<figure class="nts-card">
									<blockquote class="nts-quote">
										<p>{t.quote}</p>
									</blockquote>
									<figcaption class="nts-person">
										<span class="nts-name">{t.name}</span>
										{t.role && <span class="nts-role">{t.role}</span>}
									</figcaption>
									<span class="nts-glyph nts-glyph--phone" aria-hidden="true">
										<svg viewBox="0 0 118 75" width="46" height="29" focusable="false">
											<path d="M24 4C12 4 3 13 3 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42C45 14 36 4 24 4Z" />
											<path d="M86 4C74 4 65 13 65 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42 0-16-9-26-21-26Z" />
										</svg>
									</span>
								</figure>
							</div>
						</div>
					);
				})
			}

			<div class="nts-bottom" data-nts-appear="right">
				<div class="nts-tabs" role="tablist" aria-label={tabsLabel}>
					{
						items.map((t, i) => (
							<button
								type="button"
								class="nts-tab"
								id={`${uid}-tab-${i + 1}`}
								role="tab"
								aria-selected={i === active ? "true" : "false"}
								aria-controls={`${uid}-panel-${i + 1}`}
								tabindex={i === active ? "0" : "-1"}
								data-nts-tab
							>
								<span class="nts-num">{pad(i + 1)}</span>
								<span class="nts-sr">: {t.name}</span>
								<span class="nts-line" aria-hidden="true" />
							</button>
						))
					}
				</div>
				<span class="nts-glyph nts-glyph--desk" aria-hidden="true">
					<svg viewBox="0 0 118 75" width="116" height="74" focusable="false">
						<path d="M24 4C12 4 3 13 3 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42C45 14 36 4 24 4Z" />
						<path d="M86 4C74 4 65 13 65 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42 0-16-9-26-21-26Z" />
					</svg>
				</span>
			</div>
		</div>
	</div>
</section>
</>)}

<style>
	.nts {
		--nts-navy: var(--tg-navy, #020e28);
		--nts-orange: var(--tg-orange, #fd5523);
		--nts-muted: var(--tg-muted, #61697d);
		--nts-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
		--nts-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
		--nts-ease: cubic-bezier(0.22, 1, 0.36, 1);
		--nts-photo-w: 392px;
		--nts-col-gap: 61px;

		position: relative;
		box-sizing: border-box;
		padding: 60px 16px;
		background: var(--nts-bg);
		color: var(--nts-navy);
		font-family: var(--nts-body);
		overflow: hidden;
	}

	.nts *,
	.nts *::before,
	.nts *::after {
		box-sizing: border-box;
	}

	.nts-inner {
		display: flex;
		flex-direction: column;
		gap: 32px;
		max-width: 1224px;
		margin: 0 auto;
	}

	/* ---- title block ---- */
	.nts-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.nts .nts-eyebrow {
		display: flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--nts-display);
		text-transform: uppercase;
		color: var(--nts-orange);
	}

	.nts-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.nts-heading {
		margin: 0;
		font: 600 30px/1.2 var(--nts-display);
		text-transform: capitalize;
		color: var(--nts-navy);
	}

	/* ---- stage: phone = a list of every testimonial ---- */
	.nts-stage {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 40px;
	}

	.nts-panel {
		display: flex;
		flex-direction: column;
		gap: 24px;
		border-radius: 20px;
	}

	.nts-panel:focus-visible {
		outline: 2px solid var(--nts-orange);
		outline-offset: 6px;
	}

	.nts-photo {
		position: relative;
		overflow: hidden;
		aspect-ratio: 334 / 212;
		border-radius: 20px;
		background: #e3e6e9;
	}

	.nts-photo img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: 50% 25%;
	}

	.nts-card {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 24px;
		margin: 0;
	}

	.nts-quote {
		margin: 0;
	}

	.nts .nts-quote p {
		margin: 0;
		font: 400 20px/1.4 var(--nts-display);
		color: var(--nts-navy);
	}

	.nts-person {
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding-right: 60px;
	}

	.nts-name {
		font: 600 22px/1.1 var(--nts-display);
		color: var(--nts-navy);
	}

	.nts-role {
		font: 600 14px/1 var(--nts-display);
		text-transform: uppercase;
		color: var(--nts-orange);
	}

	.nts-glyph {
		color: rgb(253 85 35 / 0.14);
		line-height: 0;
	}

	.nts-glyph svg {
		display: block;
		fill: currentColor;
	}

	.nts-glyph--phone {
		position: absolute;
		right: 0;
		bottom: 0;
	}

	/* The tabs are a desktop/tablet control; the phone lists everything. */
	.nts-bottom {
		display: none;
	}

	/* ---- tablet and up: portrait left, switching quote right ---- */
	@media (min-width: 810px) {
		.nts {
			--nts-photo-w: 300px;
			--nts-col-gap: 40px;
			padding: 100px 30px;
		}

		.nts-inner {
			gap: 60px;
		}

		.nts-title {
			align-items: center;
			gap: 20px;
			text-align: center;
		}

		.nts .nts-eyebrow {
			font-size: 18px;
		}

		.nts-eyebrow-line {
			display: block;
		}

		.nts-heading {
			font-size: 40px;
			line-height: 1.2;
		}

		.nts-stage {
			display: grid;
			grid-template: "stack" auto / minmax(0, 1fr);
			gap: 0;
		}

		/* Every panel occupies the same cell; the active one sits on top. */
		.nts-panel {
			grid-area: stack;
			display: grid;
			grid-template-columns: var(--nts-photo-w) minmax(0, 1fr);
			column-gap: var(--nts-col-gap);
			gap: 0 var(--nts-col-gap);
			visibility: hidden;
			z-index: 0;
			/* Stay visible while the outgoing photo fades and card slides. */
			transition: visibility 0s linear 0.6s;
		}

		.nts-panel[data-pos="active"] {
			visibility: visible;
			z-index: 1;
			transition-delay: 0s;
		}

		.nts-photo-col {
			grid-column: 1;
		}

		.nts-photo {
			aspect-ratio: 392 / 452;
			border-radius: 30px;
			background: transparent;
		}

		/* Cross-fade: only the active panel's portrait is opaque. */
		.nts-photo {
			opacity: 0;
			transition: opacity 0.5s ease-out;
		}

		.nts-panel[data-pos="active"] .nts-photo {
			opacity: 1;
		}

		/* The copy column clips its card; the card slides in from the side. */
		.nts-copy {
			grid-column: 2;
			padding: 20px 0 72px;
			overflow: hidden;
		}

		.nts-card {
			gap: 48px;
			transform: translateX(calc(100% + 13px));
			transition: transform 0.6s var(--nts-ease);
		}

		.nts-panel[data-pos="before"] .nts-card {
			transform: translateX(calc(-100% - 13px));
		}

		.nts-panel[data-pos="active"] .nts-card {
			transform: none;
		}

		.nts .nts-quote p {
			font-size: 24px;
			line-height: 1.22;
		}

		.nts-person {
			gap: 19px;
			padding-right: 0;
		}

		.nts-name {
			font-size: 26px;
			line-height: 21px;
		}

		.nts-role {
			font-size: 16px;
			line-height: 12.6px;
		}

		.nts-glyph--phone {
			display: none;
		}

		/* The tab row + glyph sit at the bottom of the copy column. */
		.nts-bottom {
			position: absolute;
			z-index: 2;
			left: calc(var(--nts-photo-w) + var(--nts-col-gap));
			right: 0;
			bottom: 0;
			display: flex;
			align-items: center;
			min-height: 44px;
		}

		.nts-glyph--desk {
			position: absolute;
			right: 3px;
			bottom: 0;
			pointer-events: none;
		}

		/* Measured pitch: 44px per inactive number (24 + 20 gap), and the
		   active one plus its line (127px) then the same 20px. Each button is
		   a 44px target, so the pitch comes from min-width, not a gap. */
		.nts-tabs {
			display: flex;
			align-items: center;
			gap: 0;
		}

		.nts-tab[aria-selected="true"] {
			margin-right: 20px;
		}

		/* Tabs do nothing without the script, so they appear with it. */
		.nts:not([data-nts-ready]) .nts-tabs {
			visibility: hidden;
		}

		.nts-tab {
			display: inline-flex;
			align-items: center;
			gap: 7px;
			min-width: 44px;
			min-height: 44px;
			margin: 0;
			padding: 0;
			border: 0;
			border-radius: 6px;
			background: none;
			color: var(--nts-muted);
			font: 600 20px/18px var(--nts-display);
			cursor: pointer;
			transition: color 0.3s ease-out;
		}

		.nts-tab:hover {
			color: #f9562a;
		}

		.nts-tab[aria-selected="true"] {
			color: var(--nts-orange);
		}

		.nts-tab:focus-visible {
			outline: 2px solid var(--nts-orange);
			outline-offset: 2px;
		}

		.nts-line {
			display: block;
			width: 0;
			height: 2px;
			background: var(--nts-orange);
			transition: width 0.4s var(--nts-ease);
		}

		.nts-tab[aria-selected="true"] .nts-line {
			width: 99px;
		}
	}

	@media (min-width: 1200px) {
		.nts {
			--nts-photo-w: 392px;
			--nts-col-gap: 61px;
			padding: 120px 20px;
		}

		.nts-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.nts-copy {
			padding-bottom: 60px;
		}

		.nts-card {
			gap: 60px;
		}

		.nts .nts-quote p {
			font-size: 30px;
			line-height: 36.6px;
		}

		.nts-name {
			font-size: 30px;
		}

		.nts-role {
			font-size: 18px;
		}
	}

	/* ---- placeholder for `src: ""` ---- */
	.nts-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		border: 1px dashed rgb(2 14 40 / 0.22);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.nts-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.nts-ph-label {
		font: 500 12px/1 var(--nts-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.nts-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--nts-body);
	}

	.nts-ph-spec {
		font: 400 12px/1.2 var(--nts-body);
	}

	.nts-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- appear on scroll: start pose only once the script is ready ---- */
	[data-nts-appear] {
		transition:
			opacity 0.8s var(--nts-ease),
			transform 0.8s var(--nts-ease);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear] {
		opacity: 0;
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="up"] {
		transform: translateY(40px);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="left"] {
		transform: translateX(-40px);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="right"] {
		transform: translateX(40px);
	}

	.nts[data-in] .nts-stage [data-nts-appear] {
		transition-delay: 0.1s;
	}

	@media (prefers-reduced-motion: reduce) {
		.nts [data-nts-appear],
		.nts .nts-panel,
		.nts .nts-photo,
		.nts .nts-card,
		.nts .nts-line,
		.nts .nts-tab {
			transition: none;
		}

		.nts[data-nts-ready]:not([data-in]) [data-nts-appear] {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.nts-tab[aria-selected="true"] {
			text-decoration: underline;
		}

		.nts-line {
			background: CanvasText;
		}
	}
</style>

<script>
	/*
	 * NumberedTestimonialSwitcher controller.
	 *
	 * Automatic-activation tabs: Left/Right/Home/End move focus AND select;
	 * the roving tabindex keeps one tab in the Tab order. Selecting sets each
	 * panel's data-pos to before / active / after, and CSS does the portrait
	 * cross-fade and the card slide. One IntersectionObserver for the appear.
	 * Every listener and the observer hang off one AbortController, aborted on
	 * astro:before-swap. No timers, no autoplay, no focus moves on load.
	 */
	function initNumberedTestimonialSwitcher(root: HTMLElement) {
		if (root.dataset.ntsReady === "") return;

		const tabs = [...root.querySelectorAll<HTMLButtonElement>("[data-nts-tab]")];
		const panels = [...root.querySelectorAll<HTMLElement>("[data-nts-panel]")];
		if (tabs.length === 0 || tabs.length !== panels.length) return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		let index = Math.max(
			0,
			tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"),
		);
		const servedIndex = index;

		function select(next: number, focus: boolean) {
			const n = tabs.length;
			next = ((next % n) + n) % n;
			tabs.forEach((tab, i) => {
				const on = i === next;
				tab.setAttribute("aria-selected", on ? "true" : "false");
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.dataset.pos = i === next ? "active" : i < next ? "before" : "after";
			});
			index = next;
			if (focus) tabs[next].focus();
		}

		tabs.forEach((tab, i) => {
			tab.addEventListener("click", () => select(i, false), { signal });
			tab.addEventListener(
				"keydown",
				(event) => {
					let next: number;
					switch (event.key) {
						case "ArrowRight":
						case "ArrowDown":
							next = index + 1;
							break;
						case "ArrowLeft":
						case "ArrowUp":
							next = index - 1;
							break;
						case "Home":
							next = 0;
							break;
						case "End":
							next = tabs.length - 1;
							break;
						default:
							return;
					}
					event.preventDefault();
					select(next, true);
				},
				{ signal },
			);
		});

		/* Warm the other portraits once the section is near, so a switch
		   never waits on a lazy image. */
		const warm = () =>
			root.querySelectorAll<HTMLImageElement>(".nts-photo img").forEach((img) => {
				img.loading = "eager";
			});

		root.dataset.ntsReady = "";
		let observer: IntersectionObserver | undefined;
		if (reduced || !("IntersectionObserver" in window)) {
			root.setAttribute("data-in", "");
			warm();
		} else {
			observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.setAttribute("data-in", "");
						warm();
						observer?.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer?.disconnect());
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				select(servedIndex, false);
				root.removeAttribute("data-in");
				delete root.dataset.ntsReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-nts-root]")
			.forEach(initNumberedTestimonialSwitcher);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/NumberedTestimonialSwitcher.astro =====

===== FILE (supporting): src/components/NumberedTestimonialSwitcher.md =====
# Numbered testimonial switcher

A light testimonial section: a centred eyebrow and heading, then a rounded
portrait on the left and a big quote, name and orange role on the right.
Numbered tabs under the quote — `01 ——— 02 03 04` — switch between
testimonials: the portrait cross-fades and the new quote card slides in.

Catalog page: `/components/numbered-testimonial-switcher`

## Files required

**`NumberedTestimonialSwitcher.astro`** — one file, the whole component. No
imports, no packages, no global CSS. One small inline `<script>` for the tabs
and the appear-on-scroll.

`NumberedTestimonialSwitcher.md` — this file. Documentation, not required to run.

`public/numbered-testimonial-switcher/client-1..4.webp` — the catalog demo's
portraits (784 × 904). The component has no default testimonials, so it needs
none of them; pass your own `image` per item.

## Basic usage

```astro
---
import NumberedTestimonialSwitcher from "../components/NumberedTestimonialSwitcher.astro";
---

<NumberedTestimonialSwitcher
  testimonials={[
    {
      quote: "“They moved our peak season without a single missed pickup.”",
      name: "Maya Castillo",
      role: "Procurement Director",
      image: { src: "/img/maya.webp", alt: "Portrait of Maya Castillo" },
    },
    // up to 9
  ]}
/>
```

It is a full-bleed band: it paints its own background and padding.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Testimonial"` | Orange uppercase label with a line either side (from 810px). `""` drops it. |
| `heading` | `string?` | `"What Our Clients Say"` | Section heading. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `testimonials` | `NumberedTestimonial[]?` | `[]` | `{ quote, name, role?, image?: { src?, alt? } }`. Capped at 9 (two-digit tab labels). With none the component renders nothing. The catalog demo passes its four explicitly. |
| `initial` | `number?` | `1` | 1-based testimonial served first. |
| `tabsLabel` | `string?` | `"Choose a testimonial"` | Accessible name of the tab row. |
| `background` | `string?` | `#f6f6f6` | Band colour. |
| `fontStylesheet` | `string?` | Outfit + DM Sans (Google Fonts) | `""` to skip loading. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Root id; also seeds the tab and panel ids. |

`image: { src: "" }` renders a labelled striped placeholder with the slot size.

## Behaviour

- **Tabs.** Clicking a number selects it. The active number turns orange
  (`#fd5523`) and grows a 99 × 2px orange line after it (0.4s); hover on any
  number turns it `#f9562a` (0.3s ease-out), as measured.
- **Switch.** Every testimonial is a panel stacked in the same grid cell.
  The active panel's portrait fades in over 0.5s while the previous one fades
  out (cross-fade). Its quote card slides in to `translateX(0)` over 0.6s:
  from the right when moving to a higher number, from the left when moving
  back; the outgoing card slides out the other way. The copy column clips it.
- **No autoplay**, as measured.
- **Appear on scroll** (once, at 15% in view): the title block rises from
  `translateY(40px)`, the portrait comes in from `translateX(-40px)` and the
  copy + tab row from `translateX(40px)`, opacity 0 → 1, 0.8s ease-out.
- **Reduced motion:** the switch is instant and the appear is skipped.

## Layout

- **≥ 1200px:** 392 × 452 portrait (30px radius), 61px gap, quote in Outfit
  30/36.6, name 30px, role 18px uppercase orange. Tabs sit at the bottom of the
  copy column, level with the portrait's bottom edge; the faint quote-mark
  glyph (116 × 74) sits at the bottom right.
- **810–1199px:** 300px portrait, 40px gap, 24px quote.
- **< 810px:** the reference drops the tabs and lists every testimonial with
  its own wide photo (334 : 212), quote, name, role and a small glyph. So does
  this. The heading is left-aligned and the eyebrow lines are dropped.

## Accessibility

- Real WAI-ARIA tabs: `role="tablist"` (labelled), `role="tab"` buttons with
  `aria-selected`, `aria-controls` and a roving tabindex; each panel is
  `role="tabpanel"`, `aria-labelledby` its tab, `tabindex="0"`.
- Automatic activation: Left/Right (and Up/Down) wrap, Home and End jump.
  Focus only moves in response to those keys; nothing moves focus or scrolls
  on load.
- Each tab's name is the number plus the person ("01: Maya Castillo") via
  visually hidden text.
- Inactive panels are `visibility: hidden` (after the outgoing transition),
  so they leave the accessibility tree and the Tab order.
- Each tab is a 44px-tall, at-least-44px-wide target; the measured 44px pitch
  between inactive numbers comes from that width.
- The quote is a `<figure>` with `<blockquote>` and `<figcaption>`.
- Without JavaScript the desktop view shows the served testimonial and hides
  the tab row (it would do nothing); the phone list shows all of them.

## Images and credits

| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/numbered-testimonial-switcher/client-1.webp` | Portrait 1 | 784 × 904 | Photo: Unsplash cdZcNIca4w0, Unsplash License |
| `public/numbered-testimonial-switcher/client-2.webp` | Portrait 2 | 784 × 904 | Photo: Unsplash dpX39om7A7I, Unsplash License |
| `public/numbered-testimonial-switcher/client-3.webp` | Portrait 3 | 784 × 904 | Photo: Unsplash v7Jja2ChN6s, Unsplash License |
| `public/numbered-testimonial-switcher/client-4.webp` | Portrait 4 | 784 × 904 | Photo: Unsplash EV8PPQG6zxY, Unsplash License |

Stock portraits standing in for real customers in the catalog demo. The
demo's names and quotes are invented (Northpass Logistics is fictional) and
live in the registry, not the component — use real, permissioned
testimonials on a live site.

## Approximations

- **Slide direction.** The dump shows inactive cards parked off to the right
  of the clipped column with a `translateY(-130..-148px)` — that offset is
  exactly half each card's height, i.e. Framer's vertical-centring transform,
  not a vertical slide. So the card slides horizontally. Moving backwards
  slides from the left; the reference was only captured on tab 01, so the
  backward direction is inferred.
- **Timings** (0.5s fade, 0.6s slide, 0.4s line) are not measured; only the
  start and end states were.
- **Quote glyph** is an original inline SVG in `rgb(253 85 35 / .14)`, read off
  the screenshot; the reference uses an image.
- **Name line-height** is the measured 21px on a 30px font at desktop; it
  looks tight but matches.
- **Tablet (810–1199)** was not captured; the 300px portrait and 24px quote
  are interpolated.
- **Phone** heading 30px, quote 20px, photo 334 : 212 are read off the 390px
  screenshot.

===== END src/components/NumberedTestimonialSwitcher.md =====

===== FILE (required): public/numbered-testimonial-switcher/client-1.webp =====
[binary asset — download https://astro.baysixmedia.com/numbered-testimonial-switcher/client-1.webp and save it to public/numbered-testimonial-switcher/client-1.webp]

===== FILE (required): public/numbered-testimonial-switcher/client-2.webp =====
[binary asset — download https://astro.baysixmedia.com/numbered-testimonial-switcher/client-2.webp and save it to public/numbered-testimonial-switcher/client-2.webp]

===== FILE (required): public/numbered-testimonial-switcher/client-3.webp =====
[binary asset — download https://astro.baysixmedia.com/numbered-testimonial-switcher/client-3.webp and save it to public/numbered-testimonial-switcher/client-3.webp]

===== FILE (required): public/numbered-testimonial-switcher/client-4.webp =====
[binary asset — download https://astro.baysixmedia.com/numbered-testimonial-switcher/client-4.webp and save it to public/numbered-testimonial-switcher/client-4.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
“Numbered testimonial switcher” from my Astro Component Library:
https://astro.baysixmedia.com/components/numbered-testimonial-switcher/

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
core only
Installation files
5 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/NumberedTestimonialSwitcher.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/numbered-testimonial-switcher/client-1.webp, public/numbered-testimonial-switcher/client-2.webp, public/numbered-testimonial-switcher/client-3.webp, public/numbered-testimonial-switcher/client-4.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * NumberedTestimonialSwitcher
 *
 * A light testimonial section: centred eyebrow and heading, then a rounded
 * portrait on the left and a large quote, name and role on the right. Under
 * the quote, numbered tabs "01 ——— 02 03 04" pick the testimonial; the
 * active number carries the orange line after it. Picking one cross-fades
 * the portrait and slides its quote card in from the side (from the right
 * when moving forward, from the left when moving back). No autoplay.
 *
 * Real WAI-ARIA tabs: roving tabindex, Left/Right/Home/End, aria-selected
 * and aria-controls. Below 810px the reference drops the tabs and lists
 * every testimonial, each with its own photo; so does this.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface NumberedTestimonial {
	quote: string;
	name: string;
	role?: string;
	/** Portrait. `src: ""` renders the labelled placeholder instead. */
	image?: { src?: string; alt?: string };
}

export interface NumberedTestimonialSwitcherProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	testimonials?: NumberedTestimonial[];
	/** 1-based testimonial shown first. */
	initial?: number;
	/** Accessible name of the tab row. */
	tabsLabel?: string;
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends NumberedTestimonialSwitcherProps {}

const {
	eyebrow = "Testimonial",
	heading = "What Our Clients Say",
	headingLevel = "h2",
	/* No testimonials of its own: they come from the caller or not at all. */
	testimonials = [],
	initial = 1,
	tabsLabel = "Choose a testimonial",
	background = "#f6f6f6",
	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 ?? `nts-${Math.random().toString(36).slice(2, 9)}`;
const items = testimonials.slice(0, 9);
const active = Math.min(Math.max(1, Math.round(initial)), Math.max(1, items.length)) - 1;
const pad = (n: number) => String(n).padStart(2, "0");
const pos = (i: number) => (i === active ? "active" : i < active ? "before" : "after");

/* Measured slot: 392 × 452 at desktop; the files are 784 × 904. */
const IMAGE_SPEC = "~6:7 portrait · 784 × 904 px";
---

{items.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
	class:list={["nts", className]}
	id={id}
	style={`--nts-bg: ${background};`}
	data-nts-root
>
	<div class="nts-inner">
		<div class="nts-title" data-nts-appear="up">
			{
				eyebrow && (
					<p class="nts-eyebrow">
						<span class="nts-eyebrow-line" aria-hidden="true" />
						{eyebrow}
						<span class="nts-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			<Heading class="nts-heading">{heading}</Heading>
		</div>

		<div class="nts-stage">
			{
				items.map((t, i) => {
					const image = { src: "", alt: "", ...(t.image ?? {}) };
					return (
						<div
							class="nts-panel"
							id={`${uid}-panel-${i + 1}`}
							role="tabpanel"
							aria-labelledby={`${uid}-tab-${i + 1}`}
							tabindex="0"
							data-nts-panel
							data-pos={pos(i)}
						>
							<div class="nts-photo-col" data-nts-appear="left">
								<div class="nts-photo">
									{image.src ? (
										<img
											src={image.src}
											alt={image.alt}
											width="784"
											height="904"
											loading={i === active ? "eager" : "lazy"}
											decoding="async"
										/>
									) : (
										<div class="nts-ph" role="img" aria-label={`Image placeholder: portrait of ${t.name}`}>
											<div class="nts-ph-text" aria-hidden="true">
												<span class="nts-ph-label">Image</span>
												<span class="nts-ph-hint">Portrait of {t.name}</span>
												<span class="nts-ph-spec">{IMAGE_SPEC}</span>
											</div>
										</div>
									)}
								</div>
							</div>
							<div class="nts-copy" data-nts-appear="right">
								<figure class="nts-card">
									<blockquote class="nts-quote">
										<p>{t.quote}</p>
									</blockquote>
									<figcaption class="nts-person">
										<span class="nts-name">{t.name}</span>
										{t.role && <span class="nts-role">{t.role}</span>}
									</figcaption>
									<span class="nts-glyph nts-glyph--phone" aria-hidden="true">
										<svg viewBox="0 0 118 75" width="46" height="29" focusable="false">
											<path d="M24 4C12 4 3 13 3 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42C45 14 36 4 24 4Z" />
											<path d="M86 4C74 4 65 13 65 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42 0-16-9-26-21-26Z" />
										</svg>
									</span>
								</figure>
							</div>
						</div>
					);
				})
			}

			<div class="nts-bottom" data-nts-appear="right">
				<div class="nts-tabs" role="tablist" aria-label={tabsLabel}>
					{
						items.map((t, i) => (
							<button
								type="button"
								class="nts-tab"
								id={`${uid}-tab-${i + 1}`}
								role="tab"
								aria-selected={i === active ? "true" : "false"}
								aria-controls={`${uid}-panel-${i + 1}`}
								tabindex={i === active ? "0" : "-1"}
								data-nts-tab
							>
								<span class="nts-num">{pad(i + 1)}</span>
								<span class="nts-sr">: {t.name}</span>
								<span class="nts-line" aria-hidden="true" />
							</button>
						))
					}
				</div>
				<span class="nts-glyph nts-glyph--desk" aria-hidden="true">
					<svg viewBox="0 0 118 75" width="116" height="74" focusable="false">
						<path d="M24 4C12 4 3 13 3 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42C45 14 36 4 24 4Z" />
						<path d="M86 4C74 4 65 13 65 25s9 21 21 21c2 0 4 0 5-1-2 9-9 17-19 21l3 6c19-6 32-22 32-42 0-16-9-26-21-26Z" />
					</svg>
				</span>
			</div>
		</div>
	</div>
</section>
</>)}

<style>
	.nts {
		--nts-navy: var(--tg-navy, #020e28);
		--nts-orange: var(--tg-orange, #fd5523);
		--nts-muted: var(--tg-muted, #61697d);
		--nts-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
		--nts-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
		--nts-ease: cubic-bezier(0.22, 1, 0.36, 1);
		--nts-photo-w: 392px;
		--nts-col-gap: 61px;

		position: relative;
		box-sizing: border-box;
		padding: 60px 16px;
		background: var(--nts-bg);
		color: var(--nts-navy);
		font-family: var(--nts-body);
		overflow: hidden;
	}

	.nts *,
	.nts *::before,
	.nts *::after {
		box-sizing: border-box;
	}

	.nts-inner {
		display: flex;
		flex-direction: column;
		gap: 32px;
		max-width: 1224px;
		margin: 0 auto;
	}

	/* ---- title block ---- */
	.nts-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.nts .nts-eyebrow {
		display: flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--nts-display);
		text-transform: uppercase;
		color: var(--nts-orange);
	}

	.nts-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.nts-heading {
		margin: 0;
		font: 600 30px/1.2 var(--nts-display);
		text-transform: capitalize;
		color: var(--nts-navy);
	}

	/* ---- stage: phone = a list of every testimonial ---- */
	.nts-stage {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 40px;
	}

	.nts-panel {
		display: flex;
		flex-direction: column;
		gap: 24px;
		border-radius: 20px;
	}

	.nts-panel:focus-visible {
		outline: 2px solid var(--nts-orange);
		outline-offset: 6px;
	}

	.nts-photo {
		position: relative;
		overflow: hidden;
		aspect-ratio: 334 / 212;
		border-radius: 20px;
		background: #e3e6e9;
	}

	.nts-photo img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: 50% 25%;
	}

	.nts-card {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 24px;
		margin: 0;
	}

	.nts-quote {
		margin: 0;
	}

	.nts .nts-quote p {
		margin: 0;
		font: 400 20px/1.4 var(--nts-display);
		color: var(--nts-navy);
	}

	.nts-person {
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding-right: 60px;
	}

	.nts-name {
		font: 600 22px/1.1 var(--nts-display);
		color: var(--nts-navy);
	}

	.nts-role {
		font: 600 14px/1 var(--nts-display);
		text-transform: uppercase;
		color: var(--nts-orange);
	}

	.nts-glyph {
		color: rgb(253 85 35 / 0.14);
		line-height: 0;
	}

	.nts-glyph svg {
		display: block;
		fill: currentColor;
	}

	.nts-glyph--phone {
		position: absolute;
		right: 0;
		bottom: 0;
	}

	/* The tabs are a desktop/tablet control; the phone lists everything. */
	.nts-bottom {
		display: none;
	}

	/* ---- tablet and up: portrait left, switching quote right ---- */
	@media (min-width: 810px) {
		.nts {
			--nts-photo-w: 300px;
			--nts-col-gap: 40px;
			padding: 100px 30px;
		}

		.nts-inner {
			gap: 60px;
		}

		.nts-title {
			align-items: center;
			gap: 20px;
			text-align: center;
		}

		.nts .nts-eyebrow {
			font-size: 18px;
		}

		.nts-eyebrow-line {
			display: block;
		}

		.nts-heading {
			font-size: 40px;
			line-height: 1.2;
		}

		.nts-stage {
			display: grid;
			grid-template: "stack" auto / minmax(0, 1fr);
			gap: 0;
		}

		/* Every panel occupies the same cell; the active one sits on top. */
		.nts-panel {
			grid-area: stack;
			display: grid;
			grid-template-columns: var(--nts-photo-w) minmax(0, 1fr);
			column-gap: var(--nts-col-gap);
			gap: 0 var(--nts-col-gap);
			visibility: hidden;
			z-index: 0;
			/* Stay visible while the outgoing photo fades and card slides. */
			transition: visibility 0s linear 0.6s;
		}

		.nts-panel[data-pos="active"] {
			visibility: visible;
			z-index: 1;
			transition-delay: 0s;
		}

		.nts-photo-col {
			grid-column: 1;
		}

		.nts-photo {
			aspect-ratio: 392 / 452;
			border-radius: 30px;
			background: transparent;
		}

		/* Cross-fade: only the active panel's portrait is opaque. */
		.nts-photo {
			opacity: 0;
			transition: opacity 0.5s ease-out;
		}

		.nts-panel[data-pos="active"] .nts-photo {
			opacity: 1;
		}

		/* The copy column clips its card; the card slides in from the side. */
		.nts-copy {
			grid-column: 2;
			padding: 20px 0 72px;
			overflow: hidden;
		}

		.nts-card {
			gap: 48px;
			transform: translateX(calc(100% + 13px));
			transition: transform 0.6s var(--nts-ease);
		}

		.nts-panel[data-pos="before"] .nts-card {
			transform: translateX(calc(-100% - 13px));
		}

		.nts-panel[data-pos="active"] .nts-card {
			transform: none;
		}

		.nts .nts-quote p {
			font-size: 24px;
			line-height: 1.22;
		}

		.nts-person {
			gap: 19px;
			padding-right: 0;
		}

		.nts-name {
			font-size: 26px;
			line-height: 21px;
		}

		.nts-role {
			font-size: 16px;
			line-height: 12.6px;
		}

		.nts-glyph--phone {
			display: none;
		}

		/* The tab row + glyph sit at the bottom of the copy column. */
		.nts-bottom {
			position: absolute;
			z-index: 2;
			left: calc(var(--nts-photo-w) + var(--nts-col-gap));
			right: 0;
			bottom: 0;
			display: flex;
			align-items: center;
			min-height: 44px;
		}

		.nts-glyph--desk {
			position: absolute;
			right: 3px;
			bottom: 0;
			pointer-events: none;
		}

		/* Measured pitch: 44px per inactive number (24 + 20 gap), and the
		   active one plus its line (127px) then the same 20px. Each button is
		   a 44px target, so the pitch comes from min-width, not a gap. */
		.nts-tabs {
			display: flex;
			align-items: center;
			gap: 0;
		}

		.nts-tab[aria-selected="true"] {
			margin-right: 20px;
		}

		/* Tabs do nothing without the script, so they appear with it. */
		.nts:not([data-nts-ready]) .nts-tabs {
			visibility: hidden;
		}

		.nts-tab {
			display: inline-flex;
			align-items: center;
			gap: 7px;
			min-width: 44px;
			min-height: 44px;
			margin: 0;
			padding: 0;
			border: 0;
			border-radius: 6px;
			background: none;
			color: var(--nts-muted);
			font: 600 20px/18px var(--nts-display);
			cursor: pointer;
			transition: color 0.3s ease-out;
		}

		.nts-tab:hover {
			color: #f9562a;
		}

		.nts-tab[aria-selected="true"] {
			color: var(--nts-orange);
		}

		.nts-tab:focus-visible {
			outline: 2px solid var(--nts-orange);
			outline-offset: 2px;
		}

		.nts-line {
			display: block;
			width: 0;
			height: 2px;
			background: var(--nts-orange);
			transition: width 0.4s var(--nts-ease);
		}

		.nts-tab[aria-selected="true"] .nts-line {
			width: 99px;
		}
	}

	@media (min-width: 1200px) {
		.nts {
			--nts-photo-w: 392px;
			--nts-col-gap: 61px;
			padding: 120px 20px;
		}

		.nts-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.nts-copy {
			padding-bottom: 60px;
		}

		.nts-card {
			gap: 60px;
		}

		.nts .nts-quote p {
			font-size: 30px;
			line-height: 36.6px;
		}

		.nts-name {
			font-size: 30px;
		}

		.nts-role {
			font-size: 18px;
		}
	}

	/* ---- placeholder for `src: ""` ---- */
	.nts-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		border: 1px dashed rgb(2 14 40 / 0.22);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.nts-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.nts-ph-label {
		font: 500 12px/1 var(--nts-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.nts-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--nts-body);
	}

	.nts-ph-spec {
		font: 400 12px/1.2 var(--nts-body);
	}

	.nts-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- appear on scroll: start pose only once the script is ready ---- */
	[data-nts-appear] {
		transition:
			opacity 0.8s var(--nts-ease),
			transform 0.8s var(--nts-ease);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear] {
		opacity: 0;
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="up"] {
		transform: translateY(40px);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="left"] {
		transform: translateX(-40px);
	}

	.nts[data-nts-ready]:not([data-in]) [data-nts-appear="right"] {
		transform: translateX(40px);
	}

	.nts[data-in] .nts-stage [data-nts-appear] {
		transition-delay: 0.1s;
	}

	@media (prefers-reduced-motion: reduce) {
		.nts [data-nts-appear],
		.nts .nts-panel,
		.nts .nts-photo,
		.nts .nts-card,
		.nts .nts-line,
		.nts .nts-tab {
			transition: none;
		}

		.nts[data-nts-ready]:not([data-in]) [data-nts-appear] {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.nts-tab[aria-selected="true"] {
			text-decoration: underline;
		}

		.nts-line {
			background: CanvasText;
		}
	}
</style>

<script>
	/*
	 * NumberedTestimonialSwitcher controller.
	 *
	 * Automatic-activation tabs: Left/Right/Home/End move focus AND select;
	 * the roving tabindex keeps one tab in the Tab order. Selecting sets each
	 * panel's data-pos to before / active / after, and CSS does the portrait
	 * cross-fade and the card slide. One IntersectionObserver for the appear.
	 * Every listener and the observer hang off one AbortController, aborted on
	 * astro:before-swap. No timers, no autoplay, no focus moves on load.
	 */
	function initNumberedTestimonialSwitcher(root: HTMLElement) {
		if (root.dataset.ntsReady === "") return;

		const tabs = [...root.querySelectorAll<HTMLButtonElement>("[data-nts-tab]")];
		const panels = [...root.querySelectorAll<HTMLElement>("[data-nts-panel]")];
		if (tabs.length === 0 || tabs.length !== panels.length) return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		let index = Math.max(
			0,
			tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"),
		);
		const servedIndex = index;

		function select(next: number, focus: boolean) {
			const n = tabs.length;
			next = ((next % n) + n) % n;
			tabs.forEach((tab, i) => {
				const on = i === next;
				tab.setAttribute("aria-selected", on ? "true" : "false");
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.dataset.pos = i === next ? "active" : i < next ? "before" : "after";
			});
			index = next;
			if (focus) tabs[next].focus();
		}

		tabs.forEach((tab, i) => {
			tab.addEventListener("click", () => select(i, false), { signal });
			tab.addEventListener(
				"keydown",
				(event) => {
					let next: number;
					switch (event.key) {
						case "ArrowRight":
						case "ArrowDown":
							next = index + 1;
							break;
						case "ArrowLeft":
						case "ArrowUp":
							next = index - 1;
							break;
						case "Home":
							next = 0;
							break;
						case "End":
							next = tabs.length - 1;
							break;
						default:
							return;
					}
					event.preventDefault();
					select(next, true);
				},
				{ signal },
			);
		});

		/* Warm the other portraits once the section is near, so a switch
		   never waits on a lazy image. */
		const warm = () =>
			root.querySelectorAll<HTMLImageElement>(".nts-photo img").forEach((img) => {
				img.loading = "eager";
			});

		root.dataset.ntsReady = "";
		let observer: IntersectionObserver | undefined;
		if (reduced || !("IntersectionObserver" in window)) {
			root.setAttribute("data-in", "");
			warm();
		} else {
			observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.setAttribute("data-in", "");
						warm();
						observer?.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer?.disconnect());
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				select(servedIndex, false);
				root.removeAttribute("data-in");
				delete root.dataset.ntsReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-nts-root]")
			.forEach(initNumberedTestimonialSwitcher);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>