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.