Component library
Back to catalog

Stacked review cards

A centred testimonials heading over full-width review cards (photo left; quote mark, display-face quote and a person row with avatar, role and company mark right) that pile on top of each other as you scroll. Pure CSS; a plain column on phones.

testimonialsreviewsquotesstickystackstatic

Open preview in a new tab

Use this component

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

Start here

Placement prompt

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

Read it first — the files are added underneath when you copy
Add Stacked review cards from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/stacked-review-cards/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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

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

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

Install kit

The 4 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Stacked review cards"
Library page: https://astro.baysixmedia.com/components/stacked-review-cards/
Files: 4 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/StackedReviewCards.astro =====
---
/**
 * Stacked review cards
 *
 * A white testimonials section: a centred eyebrow and display heading, then
 * one full-width card per review. On tablet and desktop every card is sticky
 * at the same offset, so each new card slides up over the last as the page
 * scrolls. A card is a photo on the left and, on the right, a quote mark, the
 * quote in the display face and a person row with avatar, name, role and a
 * company mark.
 *
 * No script at all: the pile is `position: sticky`, nothing is animated. On
 * phones the cards simply follow one another, photo on top.
 *
 * Photos and avatars are props. Unset, a photo renders a labelled striped
 * placeholder saying what belongs there and at what size; an avatar renders
 * the stripes with the person's initials.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Colours and
 * fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
 */

export interface ImageSlot {
	/** Image URL. Unset renders the placeholder. */
	src?: string;
	/** Alt text. Decorative ("") by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder. */
	hint?: string;
}

export interface ReviewCard {
	/** The quote, without quotation marks — they are added. */
	quote: string;
	name: string;
	/** Job title. Shown in small caps under the name. */
	role?: string;
	/** Company name. Becomes the logo's alt text. */
	company?: string;
	/**
	 * Portrait, 54px round. Always decorative — the name is right beside it.
	 * Unset shows striped initials; `null` drops it.
	 */
	avatar?: ImageSlot | null;
	/** Company mark. Omit to drop it. */
	logoSrc?: string;
	/** The large photo. Unset shows the placeholder; `null` drops the panel. */
	image?: ImageSlot | null;
}

interface Props {
	/** Root id. */
	id?: string;
	/** The pill above the heading. `""` drops it. */
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	reviews?: ReviewCard[];
	/** Pile the cards with sticky positioning (810px and up). */
	stack?: boolean;
	/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
	fontStylesheet?: string;
}

const {
	id,
	eyebrow = "Testimonials",
	heading = "What our clients say",
	headingLevel = "h2",
	/* No reviews of its own: they come from the caller or not at all. */
	reviews = [],
	stack = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;

const Heading = headingLevel;

/* The photo panel at desktop: 566 x 541 in the reference. */
const PHOTO_SPEC = "~1:1 · 1200 × 1150 px";
const DEFAULT_PHOTO_HINT = "Candid photo of the customer's team at work, shot in their own warehouse or storefront";
const initials = (name: string) =>
	name
		.split(/\s+/)
		.filter(Boolean)
		.slice(0, 2)
		.map((part) => part[0]!.toUpperCase())
		.join("");
---

{reviews.length > 0 && (
<section class="src" id={id} data-src-root data-stack={stack ? "" : undefined}>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="src__inner">
		<header class="src__head">
			{eyebrow && <p class="src__eyebrow">{eyebrow}</p>}
			<Heading class="src__heading">{heading}</Heading>
		</header>

		<ul class="src__list">
			{
				reviews.map((review) => {
					const image = review.image === undefined ? {} : review.image;
					const avatar = review.avatar === undefined ? {} : review.avatar;
					const hint = image?.hint ?? DEFAULT_PHOTO_HINT;
					return (
					<li class="src__item">
						<figure class:list={["src__card", { "src__card--text": !image }]}>
							{image && (
								<div class="src__media">
									{image.src ? (
										<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />
									) : (
										<div class="src-ph" role="img" aria-label={`Image placeholder: ${hint}`}>
											<span class="src-ph__label">Image</span>
											<span class="src-ph__hint">{hint}</span>
											<span class="src-ph__spec">{PHOTO_SPEC}</span>
										</div>
									)}
								</div>
							)}
							<div class="src__content">
								<div class="src__quote">
									<svg class="src__mark" viewBox="0 0 65 44" aria-hidden="true">
										<path d="M14 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 14 0Zm37 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 51 0Z" fill="currentColor" />
									</svg>
									<blockquote class="src__text">
										<p>“{review.quote}”</p>
									</blockquote>
								</div>
								<figcaption class="src__author">
									<span class="src__person">
										{avatar && avatar.src && (
											<img class="src__avatar" src={avatar.src} alt="" width="54" height="54" loading="lazy" decoding="async" />
										)}
										{avatar && !avatar.src && (
											<span class="src__avatar src-ph src-ph--avatar" aria-hidden="true">{initials(review.name)}</span>
										)}
										<span class="src__who">
											<span class="src__name">{review.name}</span>
											{(review.role || (review.company && !review.logoSrc)) && (
												<span class="src__role">
													{review.role}
													{review.company && !review.logoSrc && (review.role ? `, ${review.company}` : review.company)}
												</span>
											)}
										</span>
									</span>
									{review.logoSrc && (
										<img class="src__logo" src={review.logoSrc} alt={review.company ?? ""} loading="lazy" decoding="async" />
									)}
								</figcaption>
							</div>
						</figure>
					</li>
					);
				})
			}
		</ul>
	</div>
</section>
)}

<style>
	.src {
		--src-navy: var(--lx-navy, #0a2540);
		--src-surface: var(--lx-surface, #f6f6f6);
		--src-ink-soft: var(--lx-ink-soft, #555555);
		--src-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--src-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--src-top: 50px;

		box-sizing: border-box;
		background: #fff;
		padding: 60px 20px;
		font-family: var(--src-body);
		color: var(--src-navy);
		overflow-x: clip;
	}

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

	.src__inner {
		max-width: 1200px;
		margin: 0 auto;
		display: flex;
		flex-direction: column;
		gap: 40px;
	}

	/* ---- heading ---- */

	.src__head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.src__eyebrow {
		margin: 0;
		padding: 7px 15px;
		border: 1px solid rgba(10, 37, 64, 0.14);
		border-radius: 100px;
		font: 500 14px/14px var(--src-body);
		text-transform: uppercase;
	}

	.src__heading {
		margin: 0;
		max-width: 420px;
		font: 400 30px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
		text-wrap: balance;
	}

	/* ---- cards ---- */

	.src__list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 24px;
	}

	.src__card {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding: 10px;
		border-radius: 10px;
		background: var(--src-surface);
	}

	.src__media {
		overflow: hidden;
		border-radius: 10px;
		aspect-ratio: 566 / 541;
		background: #dfe6ee;
	}

	.src__media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* ---- image placeholder (light context) ---- */

	.src-ph {
		display: grid;
		place-content: center;
		justify-items: center;
		gap: 8px;
		width: 100%;
		height: 100%;
		padding: 16px;
		border: 1px dashed rgba(10, 37, 64, 0.22);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgba(10, 37, 64, 0.62);
		text-align: center;
		font-family: var(--src-body);
	}

	.src-ph__label {
		font: 500 11px/1 var(--src-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

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

	.src-ph__spec {
		font: 400 12px/1.2 var(--src-body);
	}

	/* Too small for words: stripes and initials only. */
	.src-ph--avatar {
		padding: 0;
		gap: 0;
		border-radius: 50%;
		font: 600 13px/1 var(--src-body);
		letter-spacing: 0.04em;
	}

	.src__content {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		gap: 32px;
		padding: 12px;
		border-radius: 10px;
		background: #fff;
	}

	.src__quote {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		padding: 20px 10px 0;
	}

	.src__mark {
		width: 40px;
		height: auto;
		color: var(--src-navy);
	}

	.src__text {
		margin: 0;
	}

	.src__text p {
		margin: 0;
		font: 400 20px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.src__author {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		padding: 12px 16px;
		border-radius: 10px;
		background: var(--src-surface);
	}

	.src__person {
		display: flex;
		align-items: center;
		gap: 10px;
		min-width: 0;
	}

	.src__avatar {
		flex: none;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		object-fit: cover;
	}

	.src__who {
		display: flex;
		flex-direction: column;
		gap: 6px;
		min-width: 0;
	}

	.src__name {
		font: 400 16px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.src__role {
		color: var(--src-ink-soft);
		font: 500 12px/1.2 var(--src-body);
		text-transform: uppercase;
	}

	.src__logo {
		flex: none;
		width: auto;
		height: 36px;
		max-width: 96px;
		object-fit: contain;
	}

	/* ---- tablet and up: side by side, sticky pile ---- */

	@media (min-width: 810px) {
		.src {
			padding: 100px 30px;
		}

		.src__inner {
			gap: 56px;
		}

		.src__heading {
			font-size: 40px;
		}

		.src__list {
			gap: 40px;
		}

		.src__card {
			flex-direction: row;
			min-height: 480px;
		}

		.src__media {
			flex: 0 0 47.2%;
			aspect-ratio: auto;
		}

		.src__content {
			flex: 1 1 0;
			min-width: 0;
			padding: 20px;
			gap: 40px;
		}

		.src__quote {
			padding: 30px 20px 0;
			gap: 32px;
		}

		.src__mark {
			width: 52px;
		}

		.src__text p {
			font-size: 26px;
		}

		.src__author {
			padding: 20px;
		}

		.src__avatar {
			width: 54px;
			height: 54px;
		}

		.src__name {
			font-size: 24px;
		}

		.src__role {
			font-size: 14px;
			line-height: 1;
		}

		.src__logo {
			height: 51px;
			max-width: 120px;
		}

		[data-stack] .src__item {
			position: sticky;
			top: var(--src-top);
		}
	}

	@media (min-width: 1024px) {
		.src {
			padding: 120px 30px;
		}

		.src__inner {
			gap: 70px;
		}

		.src__heading {
			font-size: 48px;
		}

		.src__card {
			min-height: 561px;
		}

		.src__quote {
			gap: 40px;
		}

		.src__mark {
			width: 65px;
		}

		.src__text p {
			font-size: 32px;
		}

		.src__author {
			padding: 24px;
		}
	}
</style>

===== END src/components/StackedReviewCards.astro =====

===== FILE (supporting): src/components/StackedReviewCards.md =====
# Stacked review cards

A white testimonials section: a centred eyebrow and display heading, then
full-width review cards that pile on top of each other as you scroll. Each
card has a photo on the left. On the right it has a quote mark, the quote in
the display face, and a person row with an avatar, name, role and company
mark.

Catalog page: `/components/stacked-review-cards`

## Purpose

Two to four long-form testimonials, where each one deserves the whole width
of the page and a photograph. The pile keeps them from reading as a wall:
each card slides up over the last one.

For a lot of short quotes, use a grid or a carousel instead.

## Files required

**`StackedReviewCards.astro`**: the whole component. No imports, no
packages, no script and no global stylesheet. It reads `--lx-*` tokens with
fallbacks.

The catalog demo's company marks live in `public/stacked-review-cards/`:
`mark-harborline.svg`, `mark-summit.svg` and `mark-kestrel.svg`. They are
this library's own SVGs for fictional companies, passed as `logoSrc` by the
demo. The component has no default reviews, so it needs none of them.

There are no photo files. Photos and avatars show labelled placeholders
until you pass a `src` (see Images below).

`StackedReviewCards.md`: this file.

## Basic usage

```astro
<StackedReviewCards
  heading="What shippers say"
  reviews={[
    {
      quote: "Not one missed window since we switched.",
      name: "Marisol Reyes",
      role: "Operations director",
      company: "Harborline Goods",
      avatar: { src: "/img/marisol.jpg" },
      logoSrc: "/img/harborline.svg",
      image: { src: "/img/dock.jpg", alt: "" },
    },
  ]}
/>
```

## Props

| Prop | Type | Default | Notes |
| :--- | :--- | :--- | :--- |
| `id` | `string` | none | Applied to the `<section>`. |
| `eyebrow` | `string` | `"Testimonials"` | Outlined pill above the heading. `""` drops it. |
| `heading` | `string` | `"What our clients say"` | Display face, capped at 420px so it breaks onto two lines. The catalog demo passes "What shippers say about working with us". |
| `headingLevel` | `"h2" \| "h3"` | `"h2"` | |
| `reviews` | `ReviewCard[]` | `[]` | See below. With no reviews the component renders nothing. The catalog demo passes three reviews for the fictional Tidewater. |
| `stack` | `boolean` | `true` | Sticky pile from 810px. |
| `fontStylesheet` | `string` | Google Fonts URL | `""` if the page already loads the fonts. |

`ReviewCard`: `{ quote, name, role?, company?, avatar?, logoSrc?, image? }`.
`image` and `avatar` are `{ src?, alt?, hint? }` or `null`.

- Pass `quote` without quotation marks. The component adds curly quotes.
- `image: null` drops the photo panel, and the quote panel takes the whole
  card.
- `avatar: null` drops the avatar.
- With no `logoSrc`, the company is printed after the role instead.

## Images

With no `src`, a photo slot renders a striped placeholder that shows
`IMAGE`, the hint and the spec line, with `role="img"` and an `aria-label`
of "Image placeholder: {hint}".

| Slot | Default hint | Ratio · size |
| :--- | :--- | :--- |
| `image` with no `hint` | Candid photo of the customer's team at work, shot in their own warehouse or storefront | ~1:1 · 1200 × 1150 px |
| `avatar` | none (too small for words) | 1:1 · 216 × 216 px, shown at 54 px round |

- The photo panel is 566 × 541 at desktop and keeps that ratio on phones.
  It is `object-fit: cover`.
- An avatar placeholder shows the stripes and the person's initials, and is
  `aria-hidden` because the name sits right beside it.

## Behaviour

- From 810px each card's list item is `position: sticky; top: 50px`. Later
  cards paint above earlier ones, so each slides over the one before. Pure
  CSS, and nothing is animated.
- Below 810px the cards are in normal flow, with the photo stacked above the
  quote.

## Accessibility

- Each card is a `<figure>`: the quote is a `<blockquote>` and the person
  row is the `<figcaption>`.
- The quote-mark glyph is `aria-hidden`. Avatars have `alt=""` because the
  name is right beside them. The company mark's alt is the company name.
- There are no interactive elements, so there is nothing to focus.
- No motion, so there is nothing for reduced motion to switch off.

## Integration notes

- Sticky breaks under an ancestor with `overflow: hidden | auto | scroll`.
  `overflow: clip` is fine.
- The section supplies its own white surface and padding.

## Approximations

- **Phone stacking**: the phone dump does not include the cards, so it
  cannot show whether the reference keeps them sticky. I dropped the pile
  below 810px, as the sibling services section does. A card around 700px
  tall pinned on a short phone would hide its own quote.
- **Phone sizes** (quote 20px, name 16px, avatar 40px, logo 36px tall) were
  read from the 390 screenshot.
- **Tablet (810–1023px)** is interpolated: side by side, 480px minimum card
  height, 26px quote.
- **The eyebrow's hairline border** (1px, 14% navy) comes from the
  screenshot. Framer draws it on a layer the dump does not show.
- **The quote-mark glyph** is drawn for this component. It matches the
  reference's size (65px) and weight, not its outline.
- **Card height**: the reference fixes cards at 561px. Here 561px is a
  minimum, so a long quote grows the card instead of clipping.

===== END src/components/StackedReviewCards.md =====

===== FILE (required): public/stacked-review-cards/mark-harborline.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 76"><path d="M6 70 V22 Q6 6 22 6 H32 Q46 6 50 20 L60 52 L70 20 Q74 6 88 6 H98 Q114 6 114 22 V70 H92 V30 L76 70 H44 L28 30 V70Z" fill="#ff5f00"/></svg>

===== END public/stacked-review-cards/mark-harborline.svg =====

===== FILE (required): public/stacked-review-cards/mark-summit.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M38 4 L72 58 H52 L38 34 L24 58 H4Z" fill="#ff5f00"/><path d="M24 64 H52 L46 72 H30Z" fill="#ff5f00" opacity=".6"/></svg>

===== END public/stacked-review-cards/mark-summit.svg =====

===== FILE (required): public/stacked-review-cards/mark-kestrel.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M4 30 L38 14 L72 30 L56 34 L38 62 L20 34Z" fill="#ff5f00"/><path d="M38 14 L46 34 L38 62 L30 34Z" fill="#0a2540" opacity=".18"/></svg>

===== END public/stacked-review-cards/mark-kestrel.svg =====

--- 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: not applicable — this component ships no script.

--- 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
“Stacked review cards” from my Astro Component Library:
https://astro.baysixmedia.com/components/stacked-review-cards/

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
Static
Profile
core only
Installation files
4 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
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 fixture

src/components/library/StackedReviewCards.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/stacked-review-cards/mark-harborline.svg, public/stacked-review-cards/mark-summit.svg, public/stacked-review-cards/mark-kestrel.svg. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * Stacked review cards
 *
 * A white testimonials section: a centred eyebrow and display heading, then
 * one full-width card per review. On tablet and desktop every card is sticky
 * at the same offset, so each new card slides up over the last as the page
 * scrolls. A card is a photo on the left and, on the right, a quote mark, the
 * quote in the display face and a person row with avatar, name, role and a
 * company mark.
 *
 * No script at all: the pile is `position: sticky`, nothing is animated. On
 * phones the cards simply follow one another, photo on top.
 *
 * Photos and avatars are props. Unset, a photo renders a labelled striped
 * placeholder saying what belongs there and at what size; an avatar renders
 * the stripes with the person's initials.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Colours and
 * fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
 */

export interface ImageSlot {
	/** Image URL. Unset renders the placeholder. */
	src?: string;
	/** Alt text. Decorative ("") by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder. */
	hint?: string;
}

export interface ReviewCard {
	/** The quote, without quotation marks — they are added. */
	quote: string;
	name: string;
	/** Job title. Shown in small caps under the name. */
	role?: string;
	/** Company name. Becomes the logo's alt text. */
	company?: string;
	/**
	 * Portrait, 54px round. Always decorative — the name is right beside it.
	 * Unset shows striped initials; `null` drops it.
	 */
	avatar?: ImageSlot | null;
	/** Company mark. Omit to drop it. */
	logoSrc?: string;
	/** The large photo. Unset shows the placeholder; `null` drops the panel. */
	image?: ImageSlot | null;
}

interface Props {
	/** Root id. */
	id?: string;
	/** The pill above the heading. `""` drops it. */
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	reviews?: ReviewCard[];
	/** Pile the cards with sticky positioning (810px and up). */
	stack?: boolean;
	/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
	fontStylesheet?: string;
}

const {
	id,
	eyebrow = "Testimonials",
	heading = "What our clients say",
	headingLevel = "h2",
	/* No reviews of its own: they come from the caller or not at all. */
	reviews = [],
	stack = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;

const Heading = headingLevel;

/* The photo panel at desktop: 566 x 541 in the reference. */
const PHOTO_SPEC = "~1:1 · 1200 × 1150 px";
const DEFAULT_PHOTO_HINT = "Candid photo of the customer's team at work, shot in their own warehouse or storefront";
const initials = (name: string) =>
	name
		.split(/\s+/)
		.filter(Boolean)
		.slice(0, 2)
		.map((part) => part[0]!.toUpperCase())
		.join("");
---

{reviews.length > 0 && (
<section class="src" id={id} data-src-root data-stack={stack ? "" : undefined}>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="src__inner">
		<header class="src__head">
			{eyebrow && <p class="src__eyebrow">{eyebrow}</p>}
			<Heading class="src__heading">{heading}</Heading>
		</header>

		<ul class="src__list">
			{
				reviews.map((review) => {
					const image = review.image === undefined ? {} : review.image;
					const avatar = review.avatar === undefined ? {} : review.avatar;
					const hint = image?.hint ?? DEFAULT_PHOTO_HINT;
					return (
					<li class="src__item">
						<figure class:list={["src__card", { "src__card--text": !image }]}>
							{image && (
								<div class="src__media">
									{image.src ? (
										<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />
									) : (
										<div class="src-ph" role="img" aria-label={`Image placeholder: ${hint}`}>
											<span class="src-ph__label">Image</span>
											<span class="src-ph__hint">{hint}</span>
											<span class="src-ph__spec">{PHOTO_SPEC}</span>
										</div>
									)}
								</div>
							)}
							<div class="src__content">
								<div class="src__quote">
									<svg class="src__mark" viewBox="0 0 65 44" aria-hidden="true">
										<path d="M14 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 14 0Zm37 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 51 0Z" fill="currentColor" />
									</svg>
									<blockquote class="src__text">
										<p>“{review.quote}”</p>
									</blockquote>
								</div>
								<figcaption class="src__author">
									<span class="src__person">
										{avatar && avatar.src && (
											<img class="src__avatar" src={avatar.src} alt="" width="54" height="54" loading="lazy" decoding="async" />
										)}
										{avatar && !avatar.src && (
											<span class="src__avatar src-ph src-ph--avatar" aria-hidden="true">{initials(review.name)}</span>
										)}
										<span class="src__who">
											<span class="src__name">{review.name}</span>
											{(review.role || (review.company && !review.logoSrc)) && (
												<span class="src__role">
													{review.role}
													{review.company && !review.logoSrc && (review.role ? `, ${review.company}` : review.company)}
												</span>
											)}
										</span>
									</span>
									{review.logoSrc && (
										<img class="src__logo" src={review.logoSrc} alt={review.company ?? ""} loading="lazy" decoding="async" />
									)}
								</figcaption>
							</div>
						</figure>
					</li>
					);
				})
			}
		</ul>
	</div>
</section>
)}

<style>
	.src {
		--src-navy: var(--lx-navy, #0a2540);
		--src-surface: var(--lx-surface, #f6f6f6);
		--src-ink-soft: var(--lx-ink-soft, #555555);
		--src-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--src-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--src-top: 50px;

		box-sizing: border-box;
		background: #fff;
		padding: 60px 20px;
		font-family: var(--src-body);
		color: var(--src-navy);
		overflow-x: clip;
	}

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

	.src__inner {
		max-width: 1200px;
		margin: 0 auto;
		display: flex;
		flex-direction: column;
		gap: 40px;
	}

	/* ---- heading ---- */

	.src__head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.src__eyebrow {
		margin: 0;
		padding: 7px 15px;
		border: 1px solid rgba(10, 37, 64, 0.14);
		border-radius: 100px;
		font: 500 14px/14px var(--src-body);
		text-transform: uppercase;
	}

	.src__heading {
		margin: 0;
		max-width: 420px;
		font: 400 30px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
		text-wrap: balance;
	}

	/* ---- cards ---- */

	.src__list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 24px;
	}

	.src__card {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding: 10px;
		border-radius: 10px;
		background: var(--src-surface);
	}

	.src__media {
		overflow: hidden;
		border-radius: 10px;
		aspect-ratio: 566 / 541;
		background: #dfe6ee;
	}

	.src__media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* ---- image placeholder (light context) ---- */

	.src-ph {
		display: grid;
		place-content: center;
		justify-items: center;
		gap: 8px;
		width: 100%;
		height: 100%;
		padding: 16px;
		border: 1px dashed rgba(10, 37, 64, 0.22);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgba(10, 37, 64, 0.62);
		text-align: center;
		font-family: var(--src-body);
	}

	.src-ph__label {
		font: 500 11px/1 var(--src-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

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

	.src-ph__spec {
		font: 400 12px/1.2 var(--src-body);
	}

	/* Too small for words: stripes and initials only. */
	.src-ph--avatar {
		padding: 0;
		gap: 0;
		border-radius: 50%;
		font: 600 13px/1 var(--src-body);
		letter-spacing: 0.04em;
	}

	.src__content {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		gap: 32px;
		padding: 12px;
		border-radius: 10px;
		background: #fff;
	}

	.src__quote {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		padding: 20px 10px 0;
	}

	.src__mark {
		width: 40px;
		height: auto;
		color: var(--src-navy);
	}

	.src__text {
		margin: 0;
	}

	.src__text p {
		margin: 0;
		font: 400 20px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.src__author {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		padding: 12px 16px;
		border-radius: 10px;
		background: var(--src-surface);
	}

	.src__person {
		display: flex;
		align-items: center;
		gap: 10px;
		min-width: 0;
	}

	.src__avatar {
		flex: none;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		object-fit: cover;
	}

	.src__who {
		display: flex;
		flex-direction: column;
		gap: 6px;
		min-width: 0;
	}

	.src__name {
		font: 400 16px/1 var(--src-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.src__role {
		color: var(--src-ink-soft);
		font: 500 12px/1.2 var(--src-body);
		text-transform: uppercase;
	}

	.src__logo {
		flex: none;
		width: auto;
		height: 36px;
		max-width: 96px;
		object-fit: contain;
	}

	/* ---- tablet and up: side by side, sticky pile ---- */

	@media (min-width: 810px) {
		.src {
			padding: 100px 30px;
		}

		.src__inner {
			gap: 56px;
		}

		.src__heading {
			font-size: 40px;
		}

		.src__list {
			gap: 40px;
		}

		.src__card {
			flex-direction: row;
			min-height: 480px;
		}

		.src__media {
			flex: 0 0 47.2%;
			aspect-ratio: auto;
		}

		.src__content {
			flex: 1 1 0;
			min-width: 0;
			padding: 20px;
			gap: 40px;
		}

		.src__quote {
			padding: 30px 20px 0;
			gap: 32px;
		}

		.src__mark {
			width: 52px;
		}

		.src__text p {
			font-size: 26px;
		}

		.src__author {
			padding: 20px;
		}

		.src__avatar {
			width: 54px;
			height: 54px;
		}

		.src__name {
			font-size: 24px;
		}

		.src__role {
			font-size: 14px;
			line-height: 1;
		}

		.src__logo {
			height: 51px;
			max-width: 120px;
		}

		[data-stack] .src__item {
			position: sticky;
			top: var(--src-top);
		}
	}

	@media (min-width: 1024px) {
		.src {
			padding: 120px 30px;
		}

		.src__inner {
			gap: 70px;
		}

		.src__heading {
			font-size: 48px;
		}

		.src__card {
			min-height: 561px;
		}

		.src__quote {
			gap: 40px;
		}

		.src__mark {
			width: 65px;
		}

		.src__text p {
			font-size: 32px;
		}

		.src__author {
			padding: 24px;
		}
	}
</style>