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.