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: "Testimonial roster"
Library page: https://astro.baysixmedia.com/components/testimonial-roster/
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/TestimonialRoster.astro =====
---
/**
* Testimonial roster
*
* A testimonial that comes with the person attached: a portrait card carrying
* a headline statistic beside a quote card, and underneath them a roster of
* everyone who has something to say. Pick a face, the pair changes together.
*
* Built as a tab set, because that is what it is — one panel, several named
* choices, and the choice is the navigation. That buys arrow keys, Home and
* End for free, and it is what the roster looks like it should do.
*
* Self-contained: no imports, no packages, no global stylesheet, no webfont.
* Every colour and measurement is a `--tro-*` custom property on the root.
* The portraits are the only files it reaches for, and they are props.
*/
export interface TestimonialRosterStat {
/** The big number. Kept short — it is set at 42px. */
value: string;
/** The line under it. */
label: string;
}
export interface TestimonialRosterEntry {
/** Who said it. Also the roster label and the accessible name. */
name: string;
/** What they do. Shown beside the company under the quote. */
role?: string;
/** Where they do it. Shown in the roster and at the foot of the card. */
company?: string;
/** What they said. The quotation marks are drawn by the component. */
quote: string;
/**
* 0 to 5, and only ever the rating this person actually gave. Omit it and
* no stars are drawn: the component never supplies a rating on anyone's
* behalf. `0` also draws none rather than five empty ones — a rating
* nobody gave is not a rating of zero.
*/
rating?: number;
/** Portrait for the big card. */
image?: string;
/**
* Description of the portrait. Leave it out and the portrait is marked
* decorative, which is right when the name is already beside it.
*/
imageAlt?: string;
/** Roster thumbnail. Falls back to `image`. */
avatar?: string;
/** The figure over the portrait. Omit it and the portrait runs clean. */
stat?: TestimonialRosterStat;
/** The pill in the corner of the portrait. */
badge?: string;
}
interface Props {
/** Small line over the headline. `""` drops it. */
eyebrow?: string;
/** The section heading. Omit it (or `""`) and there is none. */
headline?: string;
/** Heading level for the headline, so it fits the page's outline. */
headingLevel?: "h2" | "h3" | "h4";
/**
* The people. Two or more makes a roster; one renders as a plain quote.
* None, and the component renders nothing at all: it never supplies a
* testimonial of its own.
*/
items?: TestimonialRosterEntry[];
/** Which one is showing on arrival. Clamped into range. */
initialIndex?: number;
/** Accessible name for the roster itself. */
rosterLabel?: string;
/** The page colour behind the cards. */
background?: string;
/** Headline, quote and name. */
ink?: string;
/** Eyebrow, role, counter, company — everything secondary. */
mutedInk?: string;
/** The quote card's ground. */
paper?: string;
/** The stat card, the badge tint and the arrow hover. */
accent?: string;
/** Hairlines: the card's divider, the arrow borders, the roster rules. */
rule?: string;
/** Where the content stops widening. */
maxWidth?: string;
/** Added to the root. */
class?: string;
/** Root element id. Also seeds every generated id. */
id?: string;
}
const {
eyebrow = "In their own words",
headline = "",
headingLevel = "h2",
items = [],
initialIndex = 0,
rosterLabel = "Choose a customer",
background = "#ffffff",
ink = "#20291f",
mutedInk = "#6d7569",
paper = "#f2f3ed",
accent = "#d8edaa",
rule = "#dde1d5",
maxWidth = "1120px",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/*
* The same shape every scripted component in this library uses. `id` wins when
* the caller supplies one; otherwise a random suffix, because two instances on
* one page must not emit the same ids and an index-derived id does exactly
* that. This library has already shipped that bug once.
*/
const uid = id ?? `tro-${Math.random().toString(36).slice(2, 9)}`;
/* `uid` can arrive from the `id` prop, which is caller data. It goes into id
attributes and into aria-controls, so anything that is not an identifier
character comes out first. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "tro";
const tabId = (i: number) => `${safeUid}-tab-${i}`;
const panelId = (i: number) => `${safeUid}-panel-${i}`;
/* A person with no name cannot be labelled, listed or announced. */
const people = (items ?? []).filter((p) => p?.name && p?.quote);
const count = people.length;
/* Clamped rather than trusted: an out-of-range index would select no tab at
all and leave the roster with nothing pressed. */
const start = Number.isInteger(initialIndex)
? Math.min(Math.max(initialIndex, 0), Math.max(count - 1, 0))
: 0;
/* Only schemes that can actually resolve to an image. A roster tends to be
filled from a content record rather than by hand. */
const SAFE_SRC = /^(?:https?:|data:image\/|[#/.])/i;
const safeSrc = (value?: string) =>
value && SAFE_SRC.test(value.trim()) ? value.trim() : undefined;
const pad = (n: number) => String(n).padStart(2, "0");
/* No rating supplied means no stars — never a default five. */
const stars = (rating?: number) => {
if (typeof rating !== "number" || !Number.isFinite(rating)) return 0;
return Math.min(Math.max(Math.round(rating), 0), 5);
};
const style = [
`--tro-bg:${background}`,
`--tro-ink:${ink}`,
`--tro-muted:${mutedInk}`,
`--tro-paper:${paper}`,
`--tro-accent:${accent}`,
`--tro-rule:${rule}`,
`--tro-max:${maxWidth}`,
].join(";");
---
{/* Nobody to quote, nothing to render — not even the header. */}
{count > 0 && (
<section
class:list={["testimonial-roster", className]}
id={id}
style={style}
data-tro-root
data-tro-index={start}
data-tro-count={count}
>
<div class="tro-inner">
{
(eyebrow || headline) && (
<header class="tro-head">
{eyebrow && <p class="tro-eyebrow">{eyebrow}</p>}
{headline && <Heading class="tro-headline">{headline}</Heading>}
</header>
)
}
{/*
Focusable, named and grouped for the no-script case, where this is a
scrolling strip and the only way to reach the other testimonials —
and a scroll container that cannot be focused cannot be scrolled
from the keyboard. The controller strips all three on boot, because
once it is a stack there is nothing to scroll and a focusable div
would just be a tab stop that does nothing.
*/}
<div
class="tro-panels"
tabindex="0"
role="group"
aria-label={rosterLabel}
>
{
people.map((person, i) => {
const img = safeSrc(person.image);
const rated = stars(person.rating);
return (
<article
class="tro-panel"
id={panelId(i)}
role="tabpanel"
aria-labelledby={tabId(i)}
data-tro-panel={i}
hidden={undefined}
>
<div class="tro-portrait">
{img ? (
<img
class="tro-photo"
src={img}
alt={person.imageAlt ?? ""}
width="600"
height="800"
loading={i === start ? "eager" : "lazy"}
decoding="async"
/>
) : (
<div class="tro-photo tro-photo--empty" aria-hidden="true" />
)}
{person.badge && <p class="tro-badge">{person.badge}</p>}
{person.stat && (
<div class="tro-stat" data-tro-reveal>
<span class="tro-stat-value">{person.stat.value}</span>
<span class="tro-stat-label">{person.stat.label}</span>
</div>
)}
</div>
<div class="tro-card">
<div class="tro-card-top">
{rated > 0 ? (
<span
class="tro-stars"
role="img"
aria-label={`Rated ${rated} out of 5`}
>
{Array.from({ length: rated }).map(() => (
<svg
class="tro-star"
viewBox="0 0 24 24"
width="15"
height="15"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 2.6l2.9 6.05 6.6.92-4.8 4.62 1.18 6.56L12 17.66 6.12 20.75 7.3 14.19 2.5 9.57l6.6-.92z"
/>
</svg>
))}
</span>
) : (
<span />
)}
<span class="tro-counter">
{pad(i + 1)} <span aria-hidden="true">/</span>{" "}
{pad(count)}
</span>
</div>
<div class="tro-body" data-tro-reveal>
<blockquote class="tro-quote">{person.quote}</blockquote>
<p class="tro-attrib">
<span class="tro-name">{person.name}</span>
{(person.role || person.company) && (
<span class="tro-role">
{[person.role, person.company]
.filter(Boolean)
.join(", ")}
</span>
)}
</p>
</div>
<div class="tro-card-foot">
<span class="tro-company">{person.company ?? ""}</span>
{count > 1 && (
<div class="tro-nav">
<button
type="button"
class="tro-arrow"
data-tro-step="-1"
aria-label="Previous testimonial"
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<path
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M15 5l-7 7 7 7M8 12h11"
/>
</svg>
</button>
<button
type="button"
class="tro-arrow"
data-tro-step="1"
aria-label="Next testimonial"
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<path
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7M5 12h11"
/>
</svg>
</button>
</div>
)}
</div>
</div>
</article>
);
})
}
</div>
{
count > 1 && (
<div class="tro-roster" role="tablist" aria-label={rosterLabel}>
{people.map((person, i) => {
const face = safeSrc(person.avatar) ?? safeSrc(person.image);
return (
<button
type="button"
class="tro-tab"
role="tab"
id={tabId(i)}
aria-controls={panelId(i)}
aria-selected={i === start ? "true" : "false"}
tabindex={i === start ? 0 : -1}
data-tro-tab={i}
>
{face ? (
<img
class="tro-avatar"
src={face}
alt=""
width="36"
height="36"
loading="lazy"
decoding="async"
/>
) : (
<span class="tro-avatar tro-avatar--empty" aria-hidden="true" />
)}
<span class="tro-tab-text">
<span class="tro-tab-name">{person.name}</span>
{person.company && (
<span class="tro-tab-org">{person.company}</span>
)}
</span>
</button>
);
})}
</div>
)
}
{/* Selecting a tab does not move focus, so nothing would read the new
quote. This does, briefly and politely. */}
<p class="tro-live" aria-live="polite" data-tro-live />
</div>
</section>
)}
<style>
.testimonial-roster {
--tro-radius: 24px;
--tro-pad: 32px;
--tro-gap: 18px;
--tro-card-pad: 36px;
--tro-inset: 20px;
--tro-tab-min: 154px;
--tro-tab-gap: 28px;
--tro-eyebrow-size: 11px;
--tro-headline-size: 38px;
--tro-quote-size: 34px;
--tro-stat-size: 42px;
--tro-small: 12px;
--tro-attrib-size: 14px;
/* One token, so reduced motion has one place to reach. */
--tro-duration: 450ms;
--tro-ease: ease;
/*
* The mono face carries the eyebrow, the counter, the attribution and
* the roster. It is a system stack: the component makes no network
* request, so there is no webfont to wait for and nothing to shift.
*/
--tro-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
display: block;
box-sizing: border-box;
/*
* The container is the ROOT, not .tro-inner, and that is a fix rather
* than a preference. An element is never matched by its own container
* query — a query container resolves against its nearest ANCESTOR
* container — so with the container on .tro-inner every token the
* breakpoints set on .tro-inner silently did nothing. Measured at
* 320px: the quote was still 34px and the headline still 38px.
*
* Moving it here also lines the breakpoint up with the reference's.
* .tro-inner is narrower than the root by the padding, so a 46rem query
* against it fired at a 768px window — where the reference is still two
* columns.
*/
container-type: inline-size;
background: var(--tro-bg);
color: var(--tro-ink);
font-family: inherit;
}
.testimonial-roster *,
.testimonial-roster *::before,
.testimonial-roster *::after {
box-sizing: border-box;
}
/*
* Every breakpoint below is a container query against the root, so the
* component lays out by the room it has rather than by the size of the
* window. Dropped into a narrow column it still does the right thing.
*/
/*
* Every string here arrives from a caller, and a testimonial is the kind of
* content that gets pasted in rather than written to fit. Measured without
* this: one unbreakable 56-character word in the quote pushed the document
* 360px past a 390px viewport. `anywhere` rather than `break-word` because
* only `anywhere` also shrinks the min-content size, which is what stops a
* long word forcing a grid or flex track open in the first place.
*/
.tro-headline,
.tro-eyebrow,
.tro-quote,
.tro-name,
.tro-role,
.tro-company,
.tro-stat-value,
.tro-stat-label,
.tro-badge,
.tro-tab-name {
overflow-wrap: anywhere;
}
.tro-inner {
/*
* The padding lives here, not on the root, and that is load-bearing.
* `container-type: inline-size` queries the container's CONTENT box, so
* padding on the root would make every breakpoint fire 64px early — and
* because the padding itself changes at a breakpoint, the content box
* would jump outward as it narrowed and could flip the layout back and
* forth across a single pixel. Padding-free, the root's query width is
* simply the room the component has.
*
* The cap grows by the padding so `maxWidth` keeps meaning the width of
* the CONTENT, which is what it meant when the padding was outside it.
*/
max-inline-size: calc(var(--tro-max) + var(--tro-pad) * 2);
margin-inline: auto;
padding: var(--tro-pad);
}
/* ---- header ---------------------------------------------------------- */
.tro-head {
margin-block-end: 32px;
}
.tro-eyebrow {
margin: 0 0 14px;
color: var(--tro-muted);
font-family: var(--tro-mono);
font-size: var(--tro-eyebrow-size);
font-weight: 600;
/* 1.43px on an 11px face. */
letter-spacing: 0.13em;
line-height: 1.2;
text-transform: uppercase;
}
.tro-headline {
margin: 0;
color: var(--tro-ink);
font-size: var(--tro-headline-size);
font-weight: 400;
/* -1.52px on a 38px face. */
letter-spacing: -0.04em;
line-height: 1.1;
/* Two lines that are close to equal, rather than one long and one short. */
text-wrap: balance;
}
/* ---- the stack of panels --------------------------------------------- */
/*
* Before the script runs, the panels are a horizontally scrollable strip:
* every testimonial is in the DOM, visible and reachable without a line of
* JavaScript. The controller turns the strip into a stack and shows one at
* a time. Nothing is hidden behind a script that has not run.
*/
.tro-panels {
display: flex;
gap: var(--tro-gap);
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
}
.tro-panels > .tro-panel {
flex: 0 0 100%;
scroll-snap-align: start;
}
[data-tro-ready] .tro-panels {
display: grid;
gap: 0;
overflow: visible;
scroll-snap-type: none;
}
/* Stacked in one cell so the box never resizes as panels swap. */
[data-tro-ready] .tro-panels > .tro-panel {
grid-area: 1 / 1;
flex: none;
}
[data-tro-ready] .tro-panel[hidden] {
display: none;
}
.tro-panel {
display: grid;
/* 0.38 / 0.62 of the room left after the gap. */
grid-template-columns: 0.38fr 0.62fr;
gap: var(--tro-gap);
margin: 0;
min-inline-size: 0;
}
/* ---- portrait card --------------------------------------------------- */
.tro-portrait {
position: relative;
overflow: hidden;
border-radius: var(--tro-radius);
background: var(--tro-accent);
/*
* Side by side it has no height of its own — the quote card sets the
* row and this fills it. `min-block-size` is the floor for the case
* where a very short quote would otherwise squash it.
*/
min-block-size: 280px;
}
.tro-photo {
/*
* Out of flow, and that is the point. Left in flow, an <img> with
* intrinsic dimensions sizes the card by its own aspect ratio — a
* 600x800 portrait in a 419px column made the row 557px tall and the
* quote card grew a third of its height in empty space to match.
* Absolute, the row is set by the quote card and the portrait fills
* whatever that turns out to be.
*/
position: absolute;
inset: 0;
display: block;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* The head sits high in the artwork, so the crop is taken high. */
object-position: 50% 40%;
}
.tro-photo--empty {
background: var(--tro-accent);
}
.tro-badge {
position: absolute;
inset-block-start: var(--tro-inset);
inset-inline-start: var(--tro-inset);
/* Anchored on one side only, so it shrink-wraps its text and a long
label would run out past the card's far edge and be clipped. This
makes it wrap at the same inset instead. */
max-inline-size: calc(100% - var(--tro-inset) * 2);
margin: 0;
padding: 8px 16px;
border-radius: 99px;
background: color-mix(in srgb, var(--tro-ink) 55%, transparent);
color: #fff;
font-family: var(--tro-mono);
font-size: var(--tro-eyebrow-size);
line-height: 1.4;
/* Glassy over whatever the portrait happens to be behind it. */
backdrop-filter: blur(6px);
}
.tro-stat {
position: absolute;
inset-block-end: var(--tro-inset);
inset-inline: var(--tro-inset);
padding: 18px 20px;
border-radius: 14px;
background: var(--tro-accent);
color: var(--tro-ink);
}
.tro-stat-value {
display: block;
font-size: var(--tro-stat-size);
font-weight: 400;
letter-spacing: -0.03em;
line-height: 1;
}
.tro-stat-label {
display: block;
margin-block-start: 8px;
font-family: var(--tro-mono);
font-size: var(--tro-small);
line-height: 1.4;
}
/* ---- quote card ------------------------------------------------------ */
.tro-card {
display: flex;
flex-direction: column;
padding: var(--tro-card-pad);
border-radius: var(--tro-radius);
background: var(--tro-paper);
min-inline-size: 0;
}
.tro-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
/*
* Reserved rather than derived. With `rating: 0` there are no stars and
* the row would collapse to the counter's line box, moving the quote up
* on some cards and not others.
*/
min-block-size: 17px;
}
.tro-stars {
display: inline-flex;
gap: 4px;
color: var(--tro-ink);
}
.tro-star {
display: block;
}
.tro-counter {
color: var(--tro-muted);
font-family: var(--tro-mono);
font-size: var(--tro-small);
/* Fixed digits, so the row does not twitch as the number changes. */
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tro-body {
margin-block-start: 31px;
/*
* A floor under the gap to the divider. The foot is pushed down by
* `margin-block-start: auto`, which distributes free space — and a card
* sized by its own content has none, so the divider ends up against the
* attribution. The reference leaves 23px here whatever the quote does.
*/
margin-block-end: 23px;
}
.tro-quote {
margin: 0;
color: var(--tro-ink);
font-size: var(--tro-quote-size);
font-weight: 400;
/* -0.85px on a 34px face. */
letter-spacing: -0.025em;
line-height: 1.22;
/* Curly quotes are drawn here rather than typed into the copy, so the
prop stays plain text and nothing double-quotes. */
quotes: "\201C" "\201D";
}
.tro-quote::before {
content: open-quote;
}
.tro-quote::after {
content: close-quote;
}
.tro-attrib {
display: flex;
flex-direction: column;
gap: 3px;
margin: 24px 0 0;
font-family: var(--tro-mono);
font-size: var(--tro-attrib-size);
line-height: 1.4;
}
.tro-name {
color: var(--tro-ink);
font-weight: 600;
}
.tro-role {
color: var(--tro-muted);
}
.tro-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
/* Pinned to the bottom of the card however tall the quote is. */
margin-block-start: auto;
padding-block-start: 24px;
border-block-start: 1px solid var(--tro-rule);
}
.tro-company {
color: var(--tro-muted);
font-family: var(--tro-mono);
font-size: var(--tro-small);
}
/* Dead controls do not appear: the arrows are revealed by the controller. */
.tro-nav {
display: none;
gap: 8px;
}
[data-tro-ready] .tro-nav {
display: flex;
}
.tro-arrow {
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
padding: 0;
border: 1px solid var(--tro-rule);
border-radius: 50%;
background: transparent;
color: var(--tro-ink);
cursor: pointer;
transition:
background-color 160ms var(--tro-ease),
transform 160ms var(--tro-ease);
}
.tro-arrow:hover {
background-color: var(--tro-accent);
}
.tro-arrow:active {
transform: scale(0.95);
}
/* ---- roster ---------------------------------------------------------- */
/*
* Revealed by the controller, like the arrows. Without a script the roster
* would be four buttons that do nothing, announcing themselves as a tab set
* that cannot switch anything — worse than not being there, because the
* panels are already reachable as a strip. Absent, the no-script render is
* simply four testimonials you can scroll through.
*/
.tro-roster {
display: none;
gap: var(--tro-tab-gap);
margin-block-start: 22px;
overflow-x: auto;
/* The controller scrolls the selected tab into view and reads this
to keep it off the edge when it gets there. */
scroll-padding-inline: var(--tro-tab-gap);
scrollbar-width: none;
}
[data-tro-ready] .tro-roster {
display: flex;
}
.tro-roster::-webkit-scrollbar {
display: none;
}
.tro-tab {
display: flex;
flex: 1 0 var(--tro-tab-min);
align-items: center;
gap: 10px;
padding: 0 0 17px;
border: 0;
border-block-end: 2px solid var(--tro-rule);
background: transparent;
color: var(--tro-ink);
cursor: pointer;
opacity: 0.55;
text-align: start;
transition:
opacity 180ms var(--tro-ease),
border-color 180ms var(--tro-ease);
}
.tro-tab:hover {
opacity: 0.8;
}
.tro-tab[aria-selected="true"] {
border-block-end-color: var(--tro-ink);
opacity: 1;
}
.tro-avatar {
flex: none;
inline-size: 36px;
block-size: 36px;
border-radius: 50%;
background: var(--tro-accent);
object-fit: cover;
/* A tighter crop than the big card: this is 36px, so it is all head. */
object-position: 50% 16%;
}
.tro-tab-text {
display: flex;
flex-direction: column;
gap: 2px;
min-inline-size: 0;
}
.tro-tab-name {
color: var(--tro-ink);
font-family: var(--tro-mono);
font-size: var(--tro-small);
line-height: 1.4;
}
.tro-tab-org {
color: var(--tro-muted);
font-family: var(--tro-mono);
font-size: 11px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
* Named on the classes rather than through a `:where(a, button)` group.
* Astro cannot put its scope attribute inside `:where()`, so it adds a
* second one beside it and the selector stops matching — this library has
* shipped a focus ring that computed as `medium none currentColor` for
* exactly that reason.
*/
.tro-tab:focus-visible,
.tro-arrow:focus-visible {
outline: 2px solid var(--tro-ink);
outline-offset: 3px;
}
.tro-arrow:focus-visible {
border-radius: 50%;
}
/* Announced, not shown. Not `display: none`, which is not announced. */
.tro-live {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ---- entrance -------------------------------------------------------- */
/*
* The entrance runs because a panel goes from `display: none` to displayed,
* which restarts its animations from the beginning. No class has to be
* added and removed, and no reflow has to be forced to retrigger it.
*/
@keyframes tro-reveal {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes tro-photo-in {
from {
opacity: 0.25;
transform: scale(1.035);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes tro-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
[data-tro-ready] .tro-panel [data-tro-reveal] {
animation: tro-reveal var(--tro-duration) var(--tro-ease) both;
}
[data-tro-ready] .tro-panel .tro-photo {
animation: tro-photo-in var(--tro-duration) var(--tro-ease) both;
}
/* ---- preferences ----------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
/*
* The panel still changes, and it is still legible that it changed —
* what goes is the travel and the scale. A short cross-fade is left in
* place, because the objection is to motion rather than to a change in
* opacity, and a hard cut between two blocks of text is harder to
* follow, not easier.
*/
[data-tro-ready] .tro-panel [data-tro-reveal],
[data-tro-ready] .tro-panel .tro-photo {
animation-name: tro-fade;
animation-duration: 120ms;
}
.tro-arrow,
.tro-tab {
transition-duration: 0.01ms;
}
.tro-arrow:active {
transform: none;
}
.tro-roster {
scroll-behavior: auto;
}
}
/* ---- responsive ------------------------------------------------------ */
/*
* Side by side, the portrait gets 0.38 of the row. Below about 46rem that
* leaves it under 250px against a quote set at 34px, and the quote card
* starts breaking words rather than lines. So the pair stacks, the portrait
* takes a fixed band, and the type steps down.
*/
@container (max-width: 47rem) {
.tro-inner {
--tro-pad: 20px;
--tro-pad: 20px;
--tro-card-pad: 24px;
--tro-headline-size: 30.4px;
--tro-quote-size: 27.2px;
--tro-stat-size: 34px;
--tro-tab-gap: 20px;
}
.tro-panel {
grid-template-columns: minmax(0, 1fr);
}
.tro-portrait {
block-size: 280px;
min-block-size: 0;
}
.tro-card-foot {
flex-wrap: wrap;
}
}
@container (max-width: 27rem) {
.tro-inner {
--tro-headline-size: 26px;
--tro-quote-size: 22px;
}
.tro-card-top {
align-items: flex-start;
}
}
/* ---- forced colours and print ---------------------------------------- */
@media (forced-colors: active) {
/*
* The tints are all colour on colour and none of them survive a forced
* palette, so the structure is redrawn in border terms, which do.
*/
.tro-portrait,
.tro-card,
.tro-stat {
border: 1px solid CanvasText;
}
.tro-badge {
border: 1px solid CanvasText;
background: Canvas;
color: CanvasText;
backdrop-filter: none;
}
.tro-arrow {
border-color: CanvasText;
color: CanvasText;
}
.tro-arrow:hover {
background-color: Highlight;
color: HighlightText;
}
.tro-tab {
/* Opacity is not a colour and a forced palette will not fix it, so
the inactive tabs are brought back to full and the selected one
is marked by its rule alone. */
opacity: 1;
border-block-end-color: CanvasText;
}
.tro-tab[aria-selected="true"] {
border-block-end-color: Highlight;
}
.tro-tab:focus-visible,
.tro-arrow:focus-visible {
outline-color: Highlight;
}
}
@media print {
.testimonial-roster {
background: #fff;
color: #000;
}
.tro-nav,
.tro-roster {
display: none;
}
/* On paper there is no "current" panel, so they all print. */
[data-tro-ready] .tro-panel[hidden] {
display: grid;
}
[data-tro-ready] .tro-panel [data-tro-reveal],
[data-tro-ready] .tro-panel .tro-photo {
animation: none;
opacity: 1;
transform: none;
}
}
</style>
<script>
/*
* Roster controller.
*
* One controller per root, booted idempotently so it is safe to run again
* after a client-side navigation, and torn down on `astro:before-swap` so
* nothing survives into the next page.
*
* Every listener goes on one AbortController per instance. Teardown is
* then a single `abort()` rather than a list of removeEventListener calls
* that has to be kept in step with the list above it.
*/
type Instance = { destroy: () => void };
const BOOTED = new WeakMap<HTMLElement, Instance>();
function boot(root: HTMLElement) {
if (BOOTED.has(root)) return;
const panels = Array.from(
root.querySelectorAll<HTMLElement>("[data-tro-panel]"),
);
const tabs = Array.from(root.querySelectorAll<HTMLElement>("[data-tro-tab]"));
const live = root.querySelector<HTMLElement>("[data-tro-live]");
if (panels.length === 0) return;
const controller = new AbortController();
const { signal } = controller;
const count = panels.length;
const parsed = Number(root.dataset.troIndex);
let index = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), count - 1) : 0;
/* Wrapping is a modulo rather than a clamp, so the arrows loop in both
directions and neither one is ever a dead control. */
const wrap = (i: number) => ((i % count) + count) % count;
const roster = root.querySelector<HTMLElement>(".tro-roster");
/*
* Keeps the selected face on screen when the roster is a scroller,
* which it is on a phone. Scrolls the roster and nothing else: this
* used to be scrollIntoView, which also scrolls every ancestor that
* can scroll - the window included - so a roster below the fold
* dragged the page down to itself on load with no input at all.
* Measured from rects rather than offsetLeft, which is relative to
* whatever the offsetParent happens to be in the host page.
*/
function revealTab(tab: HTMLElement) {
if (!roster || roster.scrollWidth <= roster.clientWidth) return;
const pad = parseFloat(getComputedStyle(roster).scrollPaddingLeft) || 0;
const box = roster.getBoundingClientRect();
const rect = tab.getBoundingClientRect();
const start = rect.left - box.left + roster.scrollLeft;
const end = start + rect.width;
if (start - pad < roster.scrollLeft) {
roster.scrollLeft = start - pad;
} else if (end + pad > roster.scrollLeft + roster.clientWidth) {
roster.scrollLeft = end + pad - roster.clientWidth;
}
}
function render(next: number, moveFocus: boolean) {
index = wrap(next);
root.dataset.troIndex = String(index);
panels.forEach((panel, i) => {
if (i === index) panel.removeAttribute("hidden");
else panel.setAttribute("hidden", "");
});
tabs.forEach((tab, i) => {
const on = i === index;
tab.setAttribute("aria-selected", on ? "true" : "false");
/* Roving tabindex: one stop for the whole roster, not one per
person, which is what the tab pattern asks for. */
tab.tabIndex = on ? 0 : -1;
});
const current = tabs[index];
if (current) {
if (moveFocus) current.focus();
revealTab(current);
}
if (live) {
const panel = panels[index];
const name = panel?.querySelector(".tro-name")?.textContent?.trim() ?? "";
const org = panel?.querySelector(".tro-company")?.textContent?.trim() ?? "";
/* Deliberately short: the panel itself is what a reader moves to
next, and repeating the whole quote here would say it twice. */
live.textContent = `Testimonial ${index + 1} of ${count}${
name ? `, ${name}` : ""
}${org ? `, ${org}` : ""}`;
}
}
root.addEventListener(
"click",
(event) => {
const target = event.target as Element | null;
const tab = target?.closest<HTMLElement>("[data-tro-tab]");
if (tab && root.contains(tab)) {
render(Number(tab.dataset.troTab), false);
return;
}
const arrow = target?.closest<HTMLElement>("[data-tro-step]");
if (arrow && root.contains(arrow)) {
render(index + Number(arrow.dataset.troStep), false);
/* The arrow that was clicked belongs to the panel that is
about to be hidden, so focus would land on <body>. Put it
on the equivalent arrow in the panel that replaced it. */
const step = arrow.dataset.troStep;
const replacement = panels[index]?.querySelector<HTMLElement>(
`[data-tro-step="${step}"]`,
);
replacement?.focus();
}
},
{ signal },
);
root.addEventListener(
"keydown",
(event) => {
const target = event.target as Element | null;
const tab = target?.closest<HTMLElement>("[data-tro-tab]");
if (!tab || !root.contains(tab)) return;
/*
* Measured from the tab that has FOCUS, not from the selected
* index. Under the tab pattern's automatic activation the two
* are the same, and they stay the same as long as nothing else
* moves focus — which is exactly the assumption worth not
* making. Reading it off the event's own target cannot drift.
*/
const from = Number(tab.dataset.troTab);
const keys: Record<string, number> = {
ArrowLeft: from - 1,
ArrowRight: from + 1,
ArrowUp: from - 1,
ArrowDown: from + 1,
Home: 0,
End: count - 1,
};
const next = keys[event.key];
if (next === undefined) return;
event.preventDefault();
render(next, true);
},
{ signal },
);
/* The strip the no-script markup renders may have been scrolled before
the controller took over; the stack has no scroll, so reset it, and
give up the tab stop it only needed while it was a scroller. */
const strip = root.querySelector<HTMLElement>(".tro-panels");
const stripLabel = strip ? strip.getAttribute("aria-label") : null;
if (strip) {
strip.scrollLeft = 0;
strip.removeAttribute("tabindex");
strip.removeAttribute("role");
strip.removeAttribute("aria-label");
}
root.dataset.troReady = "";
render(index, false);
BOOTED.set(root, {
destroy() {
controller.abort();
delete root.dataset.troReady;
/* Leave the DOM the way the server sent it, so a re-boot starts
from the same place a fresh render would — including the
attributes boot took off the strip. */
panels.forEach((panel) => panel.removeAttribute("hidden"));
if (strip) {
strip.tabIndex = 0;
strip.setAttribute("role", "group");
if (stripLabel !== null) strip.setAttribute("aria-label", stripLabel);
}
BOOTED.delete(root);
},
});
}
function bootAll() {
document
.querySelectorAll<HTMLElement>("[data-tro-root]")
.forEach((root) => boot(root));
}
function teardownAll() {
document.querySelectorAll<HTMLElement>("[data-tro-root]").forEach((root) => {
BOOTED.get(root)?.destroy();
});
}
/* Astro's <script> is a deferred module, so the document is already parsed
by the time this runs and there is no DOMContentLoaded to wait for. */
bootAll();
document.addEventListener("astro:page-load", bootAll);
document.addEventListener("astro:before-swap", teardownAll);
</script>
===== END src/components/TestimonialRoster.astro =====
===== FILE (supporting): src/components/TestimonialRoster.md =====
# Testimonial roster
A testimonial that comes with the person attached: a portrait card carrying a
headline figure beside the quote, and under them a roster of everyone who has
something to say. Pick a face and the pair changes together.
Catalog page: `/components/testimonial-roster`
## Copying it into another Astro project
Copy **`TestimonialRoster.astro`**, plus whatever portraits you use.
| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--tro-*` property on the root |
| Webfonts | none — system stack, including the mono face |
| Icon sets | none — the star and the arrows are inline `<svg>` |
| **Images** | **yes** — one portrait per person, passed as props |
| Network requests | only the portraits you point it at |
| Host requirement | Astro compiles `.astro` files |
The catalog demo passes four portraits at `/testimonial-roster/portrait-*.svg`.
They are this library's own artwork and they are **not** part of the
component — the component has no people, quotes or portraits of its own, and
renders nothing until you pass `items`.
### Exact steps
1. Copy `TestimonialRoster.astro` into your components folder.
2. Put your own portraits in `public/` (the demo SVGs in
`public/testimonial-roster/` are only needed to reproduce the catalog demo).
3. Use it:
```astro
---
import TestimonialRoster from "../components/TestimonialRoster.astro";
---
<TestimonialRoster
eyebrow="What our customers say"
headline="Ask the people who run it every day."
items={[
{
name: "Ivy Sandoval",
role: "Head of Operations",
company: "Lantern Row",
quote: "We stopped keeping the schedule in three places.",
image: "/img/ivy.jpg",
stat: { value: "9h", label: "Given back to the team each week" },
},
]}
/>
```
No client directive and no wrapper. The controller boots itself.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string` | `"In their own words"` | Small line over the headline. `""` drops it. |
| `headline` | `string` | `""` | The section heading. Omitted, there is none. |
| `headingLevel` | `"h2" \| "h3" \| "h4"` | `"h2"` | So it fits your page outline. |
| `items` | `TestimonialRosterEntry[]` | `[]` | The people. **None, and nothing renders at all** — not even the header. |
| `initialIndex` | `number` | `0` | Who is showing on arrival. Clamped into range. |
| `rosterLabel` | `string` | `"Choose a customer"` | Accessible name for the roster. |
| `background` | `string` | `"#ffffff"` | The page colour behind the cards. |
| `ink` | `string` | `"#20291f"` | Headline, quote, name. |
| `mutedInk` | `string` | `"#6d7569"` | Eyebrow, role, counter, company. |
| `paper` | `string` | `"#f2f3ed"` | The quote card's ground. |
| `accent` | `string` | `"#d8edaa"` | Stat card, badge tint, arrow hover. |
| `rule` | `string` | `"#dde1d5"` | Hairlines and arrow borders. |
| `maxWidth` | `string` | `"1120px"` | Where the **content** stops widening. |
| `class` | `string` | — | Added to the root. |
| `id` | `string` | — | Root id. Also seeds every generated id. |
### The types
```ts
interface TestimonialRosterEntry {
name: string; // also the roster label and accessible name
role?: string; // "Head of Operations"
company?: string; // shown in the roster and at the card's foot
quote: string; // the component draws the quotation marks
rating?: number; // 0–5; omit (or 0) and no stars are drawn
image?: string; // portrait for the big card
imageAlt?: string; // omit → decorative, which is right beside a name
avatar?: string; // roster thumbnail; falls back to `image`
stat?: { value: string; label: string };
badge?: string; // the pill in the portrait's corner
}
```
A person with no `name` or no `quote` is dropped. An `image` that is not
`http:`, `https:`, `data:image/` or a path is dropped too — a roster tends to
be filled from a content record rather than typed by hand.
Stars appear only for a `rating` you pass. Leave it out and there are **no**
stars — the component never fills in a rating on a customer's behalf, so a
page generated from real quotes cannot publish review evidence nobody gave.
`rating: 0` also draws none rather than five empty ones, because a rating
nobody gave is not a rating of zero. The catalog demo's people (passed from
the registry, not built in) state `rating: 5` explicitly.
The component supplies no trust data of its own: no people, quotes, ratings,
figures, badges, portraits or headline. The demo cast, its "Customer story"
badges and its figures live in the catalog registry's demo `props`.
## Behaviour
- **It is a tab set.** The roster is `role="tablist"`, each person is a `tab`,
each testimonial is a `tabpanel`. That is not decoration: it is what gets you
arrow keys, Home and End for nothing, and it is what the roster looks like it
should do.
- **Arrow keys** move between people, wrapping at both ends. **Home** and
**End** jump to the first and last. Selection follows focus, which is the tab
pattern's automatic activation.
- **Roving tabindex**: the roster is one tab stop, not one per person.
- **The prev/next arrows wrap** in both directions, so neither is ever a dead
control. Clicking one moves focus to the equivalent arrow in the panel that
replaced it — otherwise focus would land on `<body>`, because the button you
clicked has just been hidden.
- **No autoplay.** Nothing moves unless somebody asks it to.
- On a narrow screen the roster is a horizontal scroller, and selecting a
person scrolls them into view.
## The entrance
Two animations, both 450ms on `ease`:
| | From | To |
| :--- | :--- | :--- |
| `tro-reveal` (quote block, stat card) | `opacity: 0`, `translateY(12px)` | `opacity: 1`, no offset |
| `tro-photo-in` (portrait) | `opacity: 0.25`, `scale(1.035)` | `opacity: 1`, no scale |
They re-run without any class being added or removed: a panel goes from
`display: none` to displayed, and that restarts its animations from the
beginning. No forced reflow, no `requestAnimationFrame` dance.
## Without JavaScript
Nothing is hidden behind a script that has not run:
- All the testimonials are in the DOM and **visible**, as a horizontally
scrolling strip with snap points.
- The strip is focusable and labelled, so it can be scrolled from the keyboard
— a scroll container that cannot be focused cannot be scrolled. The
controller gives that tab stop up on boot, when there is nothing left to
scroll.
- Each panel shows its own `01 / 04`, so position is still legible.
- **The roster and the arrows are not rendered as dead controls.** They are
revealed only once the controller claims the component. Four buttons that
announce themselves as a tab set and switch nothing would be worse than not
being there, since the panels are already reachable.
## Accessibility
- Full tab-set semantics with `aria-controls`, `aria-labelledby` and exactly
one `aria-selected="true"` per instance.
- A polite live region announces "Testimonial 2 of 4, Name, Company" on every
change. Deliberately short: selecting a tab does not move focus, so something
has to say the panel changed — but repeating the whole quote would say it
twice.
- Every control clears a 24px target; the arrows are 44px.
- The focus ring is 2px at 3px offset, on the classes directly rather than
through `:where()`. See the trap below.
- `prefers-reduced-motion: reduce` replaces both entrance animations with a
120ms **opacity-only** fade. The panel still changes and it is still legible
that it changed; what goes is the travel and the scale. A hard cut between
two blocks of text is harder to follow, not easier.
- `forced-colors: active` redraws the structure in border terms, since every
tint here is colour on colour. The roster's inactive tabs come back to full
opacity, because opacity is not a colour and a forced palette will not fix
it; the selection is marked by `Highlight` on its rule instead.
- All caller text sets `overflow-wrap: anywhere`. Without it one unbreakable
56-character word pushed the document 360px past a 390px viewport.
## Two traps this component walked into
Both are worth knowing if you edit it, because neither fails loudly.
**An element is never matched by its own container query.** A query container
resolves against its nearest *ancestor* container, so tokens set on the
container itself do nothing. Written that way first, the type never stepped
down: measured at 320px the quote was still 34px.
**`container-type: inline-size` queries the content box.** Padding on the
container makes every breakpoint fire early by twice the padding — and if the
padding itself changes at a breakpoint, the content box grows as the window
narrows and the layout can flip back and forth across one pixel. The padding
here is on `.tro-inner` for exactly that reason, and `maxWidth` grows by it so
the prop still means the width of the content.
## Relationship to the reference
Rebuilt from a live page by measurement. No markup, CSS or copy was taken from
it: the cast, the quotes, the statistics and all the artwork are original to
this library.
Measured against the reference at 1440px and matched exactly: the
`0.38fr / 0.62fr` columns at an 18px gap (418.75px / 683.25px), the 1120px
content width, the quote at 34px/41.48px with −0.85px of tracking, the 379×103
stat card, the 44px arrows and the 36px avatars. The card comes out 442px tall
against its 446px — the difference is line-box height in the mono face, since
the reference uses a webfont and this uses a system stack.
The motion is the reference's own, read off its running animations rather than
guessed: 450ms, `ease`, `translateY(12px)` and `scale(1.035)`.
Three deliberate departures:
1. Its roster is a group of `aria-pressed` toggle buttons. This is a real tab
set, so arrow keys, Home and End work.
2. Its reduced-motion rule kills every animation and transition with
`!important`. This keeps a short opacity fade.
3. It has no `overflow-wrap`, so it has the long-word overflow this one was
measured for and fixed.
===== END src/components/TestimonialRoster.md =====
===== FILE (required): public/testimonial-roster/portrait-1.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
<defs><linearGradient id="p1" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#5c7248"/><stop offset="1" stop-color="#2f3d25"/></linearGradient></defs>
<rect width="600" height="800" fill="url(#p1)"/>
<circle cx="470" cy="146" r="38" fill="#d8edaa" opacity="0.85"/>
<path d="M300 188c-84 0-126 54-126 132 0 46 10 86 24 118 8-52 4-96 18-132 26 26 62 40 102 40s72-14 96-38c12 36 10 80 18 130 14-32 22-72 22-118 0-78-40-132-124-132z" fill="#2b2119"/>
<circle cx="300" cy="322" r="112" fill="#efe4d2"/>
<path d="M132 800c0-104 74-176 168-176s168 72 168 176z" fill="#efe4d2" opacity="0.96"/>
</svg>
===== END public/testimonial-roster/portrait-1.svg =====
===== FILE (required): public/testimonial-roster/portrait-2.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
<defs><linearGradient id="p2" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#b4704a"/><stop offset="1" stop-color="#5e3323"/></linearGradient></defs>
<rect width="600" height="800" fill="url(#p2)"/>
<circle cx="470" cy="146" r="38" fill="#ffd9a8" opacity="0.85"/>
<path d="M300 214c-64 0-104 36-108 88 0 12 2 24 6 34 10-40 44-62 102-62s92 22 102 62c4-10 6-22 6-34-4-52-44-88-108-88z" fill="#241a14"/>
<circle cx="300" cy="330" r="106" fill="#f3ddc6"/>
<path d="M120 800c0-110 80-186 180-186s180 76 180 186z" fill="#f3ddc6" opacity="0.96"/>
</svg>
===== END public/testimonial-roster/portrait-2.svg =====
===== FILE (required): public/testimonial-roster/portrait-3.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
<defs><linearGradient id="p3" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#4a5f8c"/><stop offset="1" stop-color="#232c45"/></linearGradient></defs>
<rect width="600" height="800" fill="url(#p3)"/>
<circle cx="470" cy="146" r="38" fill="#c8d6ff" opacity="0.85"/>
<path d="M302 186c-88 0-130 58-130 140 0 118-14 196-30 264h74c-14-70-8-160 0-214 24 34 56 52 86 52s62-18 86-52c8 54 14 144 0 214h74c-16-68-30-146-30-264 0-82-42-140-130-140z" fill="#1b1620"/>
<circle cx="302" cy="318" r="110" fill="#e9d8c6"/>
<path d="M136 800c0-102 74-172 166-172s166 70 166 172z" fill="#e9d8c6" opacity="0.96"/>
</svg>
===== END public/testimonial-roster/portrait-3.svg =====
===== FILE (required): public/testimonial-roster/portrait-4.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
<defs><linearGradient id="p4" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#8a7f63"/><stop offset="1" stop-color="#3d3628"/></linearGradient></defs>
<rect width="600" height="800" fill="url(#p4)"/>
<circle cx="470" cy="146" r="38" fill="#e8dcb8" opacity="0.85"/>
<g fill="#332620"><circle cx="240" cy="224" r="54"/><circle cx="300" cy="200" r="60"/><circle cx="360" cy="226" r="54"/><circle cx="206" cy="274" r="46"/><circle cx="394" cy="276" r="46"/></g>
<path d="M298 214c-70 0-112 42-112 104 0 18 4 34 10 48 8-46 46-72 102-72s94 26 102 72c6-14 10-30 10-48 0-62-42-104-112-104z" fill="#332620"/>
<circle cx="298" cy="334" r="104" fill="#f0e2cd"/>
<path d="M126 800c0-108 78-182 174-182s174 74 174 182z" fill="#f0e2cd" opacity="0.96"/>
</svg>
===== END public/testimonial-roster/portrait-4.svg =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-23 at 1920, 1440, 1037, 1024, 834, 768, 430, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle, active state integrity, long content, loop integrity, clone inertness, loop transition continuity, click intent separation, pointer drag, touch swipe, autoplay pause, autoplay reduced motion.
Library source fingerprint: sha256:db575165fa8f5eb0e7b6c08eb790948521585ea59cc45d1a8da65c0336e6cc64 — MATCHES the files below.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- 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.