The 6 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: "Portrait rating testimonials"
Library page: https://astro.baysixmedia.com/components/portrait-rating-testimonials/
Files: 6 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/PortraitRatingTestimonials.astro =====
---
/**
* PortraitRatingTestimonials
*
* A centred testimonial section: outlined eyebrow and a big uppercase
* heading over three columns. The outer columns each hold two quote cards
* (orange quote mark, quote, avatar, name, role and a small star score); the
* middle column is a tall portrait photo fading to navy at the foot, carrying
* an overall rating with five stars and a review count.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface PortraitTestimonial {
quote: string;
name: string;
role?: string;
/** Score shown in the pill, e.g. "5.0". Omit it (or `""`) and there is no pill. */
score?: string;
/** `src: ""` renders initials instead. */
avatar?: { src?: string; alt?: string };
}
export interface PortraitRatingTestimonialsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Four quotes: two left, two right. Extra ones are ignored. */
testimonials?: PortraitTestimonial[];
/** The centre photo. `src: ""` renders a labelled placeholder. */
portrait?: { src?: string; alt?: string };
/**
* The overall rating, from a real review source. Omit it (or pass `null`)
* and there is none: an aggregate rating and a review count are never
* supplied by the component. The stars follow the score's leading number.
*/
rating?: { score: string; count: string } | null;
background?: string;
/** Quote card fill. White on a pale band; pale on a white band. */
cardBackground?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends PortraitRatingTestimonialsProps {}
const {
eyebrow = "Testimonials",
heading = "What our *clients* say",
headingLevel = "h2",
/* No quotes of its own: they come from the caller or not at all. */
testimonials = [],
portrait: portraitProp,
rating = null,
background = "#f0f8fa",
cardBackground = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* Whole stars for a score like "4.9/5.0": its leading number, rounded, 0–5. */
const starsFor = (score: string) => {
const n = Number.parseFloat(score);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), 0), 5) : 0;
};
/* No photo of its own: unset (or `src: ""`) renders the labelled placeholder. */
const portrait = { src: "", alt: "", ...(portraitProp ?? {}) };
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const left = testimonials.slice(0, 2);
const right = testimonials.slice(2, 4);
const initials = (name: string) =>
name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
---
{testimonials.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["prt", className]}
id={id}
style={`--prt-band:${background};--prt-card-bg:${cardBackground}`}
data-prt-root
>
<div class="prt-inner">
<div class="prt-head">
{eyebrow && <p class="prt-eyebrow">{eyebrow}</p>}
<Heading class="prt-heading" data-prt-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["prt-w", { "prt-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
</div>
<div class="prt-grid">
{
[left, right].map((col, c) => (
<ul class:list={["prt-col", c === 0 ? "prt-col--left" : "prt-col--right"]}>
{col.map((t, i) => (
<li class="prt-card" data-prt-reveal="up" style={`--d:${(c * 2 + i) * 0.1}s`}>
<svg class="prt-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M4 34V22C4 12 8 7 17 5l1 4c-5 2-7 5-7 9h6v16Zm19 0V22c0-10 4-15 13-17l1 4c-5 2-7 5-7 9h6v16Z" fill="currentColor" />
</svg>
<blockquote class="prt-quote">
<p>{t.quote}</p>
</blockquote>
<div class="prt-person">
{t.avatar?.src ? (
<img class="prt-avatar" src={t.avatar.src} alt={t.avatar.alt ?? ""} width="40" height="40" loading="lazy" decoding="async" />
) : (
<span class="prt-avatar prt-avatar--initials" aria-hidden="true">
{initials(t.name)}
</span>
)}
<div class="prt-who">
<p class="prt-name">{t.name}</p>
{t.role && <p class="prt-role">{t.role}</p>}
</div>
{t.score && (
<p class="prt-score">
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" />
</svg>
<span class="prt-sr">Rated </span>
{t.score}
</p>
)}
</div>
</li>
))}
</ul>
))
}
<figure class="prt-portrait" data-prt-reveal="up" style="--d:0.05s">
{
portrait.src ? (
<img src={portrait.src} alt={portrait.alt} width="854" height="1254" loading="lazy" decoding="async" />
) : (
<div class="prt-ph" role="img" aria-label="Image placeholder: portrait of a site worker">
<span>Image · portrait · 854 × 1254</span>
</div>
)
}
{
rating && (
<figcaption class="prt-rating">
<span class="prt-rating-badge" aria-hidden="true">
<svg viewBox="0 0 20 20"><path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" /></svg>
</span>
<span>
<span class="prt-rating-score">{rating.score}</span>
<span class="prt-rating-line">
{starsFor(rating.score) > 0 && (
<span class="prt-stars" aria-hidden="true">{"★".repeat(starsFor(rating.score))}</span>
)}
<span class="prt-rating-count">{rating.count}</span>
</span>
</span>
</figcaption>
)
}
</figure>
</div>
</div>
</section>
</>)}
<style>
.prt {
--prt-navy: var(--tw-navy, #0b4654);
--prt-accent: var(--tw-accent, #ff6d45);
--prt-muted: var(--tw-muted, #616161);
--prt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--prt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--prt-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--prt-band);
color: var(--prt-navy);
font-family: var(--prt-body);
}
.prt *,
.prt *::before,
.prt *::after {
box-sizing: border-box;
}
.prt-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.prt-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.prt-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--prt-accent);
border-radius: 999px;
color: var(--prt-accent);
font: 500 15px/24px var(--prt-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.prt-heading {
margin: 0;
font: 700 28px/1.2 var(--prt-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.prt-w {
display: inline-block;
}
.prt-accent {
color: var(--prt-accent);
}
.prt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 15px;
margin-top: 40px;
}
.prt-col {
display: grid;
gap: 15px;
margin: 0;
padding: 0;
list-style: none;
}
.prt-card {
display: flex;
flex-direction: column;
min-width: 0;
padding: 25px;
border-radius: 20px;
background: var(--prt-card-bg);
}
.prt-mark {
width: 36px;
height: 36px;
color: var(--prt-accent);
}
.prt-quote {
margin: 16px 0 0;
}
.prt-quote p {
margin: 0;
font: 500 16px/25.6px var(--prt-body);
letter-spacing: -0.32px;
/* A long unbroken word in a real review wraps rather than pushing the page sideways. */
overflow-wrap: anywhere;
}
.prt-person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
}
.prt-avatar {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.prt-avatar--initials {
display: grid;
place-items: center;
background: var(--prt-navy);
color: #fff;
font: 600 14px/1 var(--prt-body);
}
.prt-who {
min-width: 0;
margin-right: auto;
}
.prt-name {
margin: 0;
overflow: hidden;
font: 500 18px/25.2px var(--prt-body);
letter-spacing: -0.36px;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.prt-role {
margin: 0;
color: var(--prt-muted);
font: 400 15px/24px var(--prt-body);
letter-spacing: -0.3px;
overflow-wrap: anywhere;
}
.prt-score {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
margin: 0;
padding: 2px 10px;
border: 1px solid rgb(11 70 84 / 0.12);
border-radius: 999px;
color: var(--prt-muted);
font: 600 14px/22.4px var(--prt-body);
}
.prt-score svg {
width: 13px;
height: 13px;
color: var(--prt-accent);
}
.prt-portrait {
position: relative;
min-height: 420px;
margin: 0;
overflow: hidden;
border-radius: 20px;
background: var(--prt-accent);
}
.prt-portrait img,
.prt-ph {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
.prt-portrait::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(255 255 255 / 0) 40%, var(--prt-navy) 80%, #000 100%);
}
.prt-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #ff8a67 0 12px, #ff7a53 12px 24px);
color: #fff;
font: 500 13px/1.4 var(--prt-body);
}
.prt-rating {
position: absolute;
left: 25px;
bottom: 30px;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
color: #fff;
}
.prt-rating-badge {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: #fff;
color: var(--prt-accent);
}
.prt-rating-badge svg {
width: 26px;
height: 26px;
}
.prt-rating-score {
display: block;
font: 600 30px/42px var(--prt-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.prt-rating-line {
display: flex;
align-items: center;
gap: 8px;
}
.prt-stars {
color: var(--prt-accent);
font-size: 16px;
letter-spacing: 2px;
}
.prt-rating-count {
font: 600 14px/22.4px var(--prt-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
.prt-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-prt-reveal="up"] {
transition:
opacity 0.8s var(--prt-ease) var(--d, 0s),
transform 0.8s var(--prt-ease) var(--d, 0s);
}
.prt[data-prt-ready] [data-prt-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.prt-w {
transition:
opacity 0.6s var(--prt-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--prt-ease) calc(var(--i) * 0.06s);
}
.prt[data-prt-ready] [data-prt-reveal="words"]:not([data-in]) .prt-w {
opacity: 0;
transform: translateY(10px);
}
/* Phone: left pair, portrait, right pair. */
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
}
.prt-col--right {
order: 3;
}
@media (min-width: 810px) {
.prt-inner {
padding: 90px 40px;
}
.prt-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.prt-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.prt-portrait {
grid-column: 1 / -1;
min-height: 520px;
}
}
@media (min-width: 1200px) {
.prt-inner {
padding: 100px 65px;
}
.prt-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.prt-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
margin-top: 50px;
}
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
grid-column: auto;
align-self: stretch;
min-height: 602px;
}
.prt-col--right {
order: 3;
padding-top: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-prt-reveal="up"],
.prt-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initPortraitRatingTestimonials(root: HTMLElement) {
if (root.dataset.prtReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.prtReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-prt-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.prtReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-prt-root]").forEach(initPortraitRatingTestimonials);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/PortraitRatingTestimonials.astro =====
===== FILE (supporting): src/components/PortraitRatingTestimonials.md =====
# Portrait rating testimonials
A centred testimonial section: outlined eyebrow and a big uppercase heading
over three columns. The outer columns hold two quote cards each (orange quote
mark, quote, avatar, name, role, star score); the middle is a tall portrait
fading to navy with an overall rating, five stars and a review count.
Catalog page: `/components/portrait-rating-testimonials`
## Files required
- **`PortraitRatingTestimonials.astro`** — the component, with one small appear script.
- `public/portrait-rating-testimonials/portrait.webp` and
`avatar-1.webp` … `avatar-4.webp` — the catalog demo's photos. The
component has no default photos, so it needs none of them.
- `PortraitRatingTestimonials.md` — this file.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow`, `heading`, `headingLevel` | | | Orange words in `*asterisks*`. |
| `testimonials` | `{ quote, name, role?, score?, avatar? }[]` | `[]` | First two left, next two right. No `score` (or `""`) drops the pill; no avatar → initials. With no testimonials the component renders nothing. The catalog demo passes four explicitly. |
| `portrait` | `{ src?, alt? }?` | none | Unset or `src: ""` → labelled placeholder. `alt` defaults to `""`. The catalog demo passes the worker photo. |
| `rating` | `{ score, count } \| null` | none | The overall rating from a real review source. Omit it and there is no rating block — the component never supplies one. Stars follow the score's leading number (4.9 → 5, 4.2 → 4). The catalog demo passes its rating explicitly. |
| `background` | `string?` | `#f0f8fa` | Band. |
| `cardBackground` | `string?` | `#ffffff` | Use `#f0f8fa` on a white band. |
| `fontStylesheet`, `class`, `id` | | | |
## Behaviour (measured)
Cards and portrait rise from 80px, staggered 100ms; heading words rise from
10px. No hover state on the reference.
## Images and credits
| File | Size | Credit |
| :--- | :--- | :--- |
| `portrait.webp` | 854 × 1254 | Unsplash aG_HhGao-vM, Unsplash License |
| `avatar-1.webp` | 100 × 100 | Unsplash SNvrVyUYyK0, Unsplash License |
| `avatar-2.webp` | 100 × 100 | Unsplash cdZcNIca4w0, Unsplash License |
| `avatar-3.webp` | 100 × 100 | Unsplash xhQUcSQpotU, Unsplash License |
| `avatar-4.webp` | 100 × 100 | Unsplash x27-edtdfio, Unsplash License |
The catalog demo's testimonials and names are invented copy held in the
registry, not the component. Use real, attributable reviews live.
## Approximations
- The reference shows a review platform's logo beside the rating; this uses a
generic star badge instead.
- The reference portrait is a cut-out on a flat orange field; this is a stock
portrait on a red backdrop.
===== END src/components/PortraitRatingTestimonials.md =====
===== FILE (required): public/portrait-rating-testimonials/portrait.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/portrait.webp and save it to public/portrait-rating-testimonials/portrait.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-1.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-1.webp and save it to public/portrait-rating-testimonials/avatar-1.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-2.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-2.webp and save it to public/portrait-rating-testimonials/avatar-2.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-3.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-3.webp and save it to public/portrait-rating-testimonials/avatar-3.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-4.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-4.webp and save it to public/portrait-rating-testimonials/avatar-4.webp]
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.