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: "About stats intro"
Library page: https://astro.baysixmedia.com/components/about-stats-intro/
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/AboutStatsIntro.astro =====
---
/**
* AboutStatsIntro
*
* An about-company section. Across the top, an outlined eyebrow on the left
* and a big three-line uppercase heading on the right, over a hairline.
* Below, a tall photo cut into a ticket shape (narrower tabs top and bottom)
* beside two pale stat cards (years in business; avatars, review count and a
* star score), a check-marked subheading, two paragraphs, a two-column
* checklist and an orange pill.
*
* Heading words rise in and the columns lift in the first time they scroll
* into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface AboutStatsIntroProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** No `src` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
years?: { value: string; label: string } | null;
/**
* A review count and score from a real review source. Omit it (or pass
* `null`) and there is no review block: the component never supplies a
* rating or a number of reviews.
*/
reviews?: { count: string; label: string; score: string; avatars?: string[] } | null;
subheading?: string;
paragraphs?: string[];
checklist?: string[];
cta?: { label: string; href: string } | null;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends AboutStatsIntroProps {}
/*
* The heading, copy, checklist, button and photo are the caller's: nothing
* about a business is filled in here. "About Company" is a generic label and
* never renders the section on its own. The catalog demo passes its sample
* content through the registry's `props`.
*/
const {
eyebrow = "About Company",
heading = "",
headingLevel = "h2",
image: imageProp,
years = null,
reviews = null,
subheading = "",
paragraphs = [],
checklist = [],
cta = null,
background = "#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;
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const texts = (paragraphs ?? []).filter((p) => typeof p === "string" && p.trim());
const checks = (checklist ?? []).filter((c) => typeof c === "string" && c.trim());
/* Nothing to say → nothing rendered: the generic eyebrow alone is not a section. */
const hasContent = Boolean(heading || years || reviews || subheading || texts.length > 0 || checks.length > 0 || cta);
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Sub = headingLevel === "h2" ? "h3" : "h4";
/* Stars are drawn only from a real numeric score, filled to it (4.8 of 5 fills 96%). Anything else, no stars. */
const scoreNum = reviews ? Number.parseFloat(String(reviews.score)) : Number.NaN;
const starFill = Number.isFinite(scoreNum) && scoreNum >= 0 && scoreNum <= 5 ? Math.round((scoreNum / 5) * 1000) / 10 : null;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class:list={["asi", className]} id={id} style={`--asi-band:${background}`} data-asi-root>
<div class="asi-inner">
{(eyebrow || heading) && (
<div class="asi-head">
{eyebrow && <p class="asi-eyebrow">{eyebrow}</p>}
{heading && (
<Heading class="asi-heading" data-asi-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["asi-w", { "asi-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
)}
<div class="asi-body">
<div class="asi-media" data-asi-reveal="up">
{
image.src ? (
<img src={image.src} alt={image.alt} width="848" height="1200" loading="lazy" decoding="async" />
) : (
<div class="asi-ph" role="img" aria-label="Image placeholder: tall portrait photo">
<span>Image · portrait · 848 × 1200</span>
</div>
)
}
</div>
<div class="asi-copy" data-asi-reveal="up" style="--d:0.12s">
{
(years || reviews) && (
<ul class="asi-stats">
{years && (
<li class="asi-stat">
<p class="asi-years">{years.value}</p>
<p class="asi-stat-label">{years.label}</p>
</li>
)}
{reviews && (
<li class="asi-stat">
<div class="asi-reviews-top">
{reviews.avatars && reviews.avatars.length > 0 && (
<span class="asi-avatars" aria-hidden="true">
{reviews.avatars.map((src) => (
<img src={src} alt="" width="36" height="36" loading="lazy" decoding="async" />
))}
</span>
)}
<p class="asi-count">
{reviews.count}
<span>{reviews.label}</span>
</p>
</div>
<p class="asi-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>
{reviews.score}
</p>
{starFill !== null && (
<p class="asi-stars" aria-hidden="true" style={`--asi-fill: ${starFill}%`}>
<span class="asi-stars-fill">★★★★★</span>★★★★★
</p>
)}
</li>
)}
</ul>
)
}
{
subheading && (
<Sub class="asi-sub">
<span class="asi-sub-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="m6 12.5 4 4 8-9" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
{subheading}
</Sub>
)
}
{texts.map((p) => <p class="asi-text">{p}</p>)}
{
checks.length > 0 && (
<ul class="asi-checks">
{checks.map((c) => (
<li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{c}
</li>
))}
</ul>
)
}
{
cta && (
<a class="asi-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="asi-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
</div>
</div>
</section>
)}
<style>
.asi {
--asi-navy: var(--tw-navy, #0b4654);
--asi-deep: var(--tw-navy-deep, #052931);
--asi-accent: var(--tw-accent, #ff6d45);
--asi-surface: var(--tw-surface, #f0f8fa);
--asi-muted: var(--tw-muted, #616161);
--asi-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--asi-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--asi-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--asi-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--asi-band);
color: var(--asi-navy);
font-family: var(--asi-body);
}
.asi *,
.asi *::before,
.asi *::after {
box-sizing: border-box;
}
.asi-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.asi-head {
display: grid;
gap: 18px;
justify-items: start;
padding-bottom: 40px;
border-bottom: 1px solid rgb(11 70 84 / 0.1);
}
.asi-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--asi-accent);
border-radius: 999px;
color: var(--asi-accent);
font: 500 15px/24px var(--asi-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.asi-heading {
max-width: 800px;
margin: 0;
font: 700 28px/1.2 var(--asi-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.asi-w {
display: inline-block;
}
.asi-accent {
color: var(--asi-accent);
}
.asi-body {
display: grid;
gap: 40px;
margin-top: 40px;
}
.asi-media {
aspect-ratio: 631 / 893;
max-height: 720px;
}
/* Ticket shape: full width in the middle, narrower tabs top and bottom. */
.asi-media img,
.asi-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
clip-path: polygon(
18% 0, 82% 0, 84% 1%, 85% 3%, 85% 7.5%, 87% 8.5%, 98% 8.5%, 99.5% 9.5%, 100% 11.5%,
100% 88.5%, 99.5% 90.5%, 98% 91.5%, 87% 91.5%, 85% 92.5%, 85% 97%, 84% 99%, 82% 100%,
18% 100%, 16% 99%, 15% 97%, 15% 92.5%, 13% 91.5%, 2% 91.5%, 0.5% 90.5%, 0 88.5%,
0 11.5%, 0.5% 9.5%, 2% 8.5%, 13% 8.5%, 15% 7.5%, 15% 3%, 16% 1%
);
}
.asi-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--asi-body);
}
.asi-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.asi-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
width: 100%;
margin: 0 0 36px;
padding: 0;
list-style: none;
}
.asi-stat {
padding: 22px;
border-radius: 20px;
background: var(--asi-surface);
}
.asi-years {
margin: 0;
color: var(--asi-accent);
font: 700 40px/1.2 var(--asi-display);
letter-spacing: -0.8px;
}
.asi-stat-label {
margin: 24px 0 0;
font: 600 20px/1.2 var(--asi-card-font);
letter-spacing: -0.8px;
}
.asi-reviews-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.asi-avatars {
display: flex;
}
.asi-avatars img {
width: 36px;
height: 36px;
border: 2px solid var(--asi-surface);
border-radius: 50%;
object-fit: cover;
}
.asi-avatars img + img {
margin-left: -12px;
}
.asi-count {
margin: 0;
font: 700 16px/1.3 var(--asi-card-font);
letter-spacing: -0.64px;
}
.asi-count span {
display: block;
font-weight: 600;
}
.asi-score {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 0;
color: var(--asi-accent);
font: 700 18px/25.2px var(--asi-card-font);
letter-spacing: -0.72px;
}
.asi-score svg {
width: 24px;
height: 24px;
}
/* Five pale stars with the real score's share filled over them. */
.asi-stars {
position: relative;
display: inline-block;
margin: 8px 0 0;
color: color-mix(in srgb, var(--asi-accent) 22%, transparent);
font-size: 22px;
line-height: 1;
letter-spacing: 3px;
white-space: nowrap;
}
.asi-stars-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--asi-fill);
overflow: hidden;
color: var(--asi-accent);
}
.asi-sub {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
font: 700 22px/1.4 var(--asi-card-font);
letter-spacing: -0.88px;
}
.asi-sub-ic {
display: grid;
flex: none;
place-items: center;
width: 35px;
height: 35px;
border-radius: 50%;
background: var(--asi-accent);
color: #fff;
}
.asi-sub-ic svg {
width: 20px;
height: 20px;
}
.asi-text {
max-width: 590px;
margin: 14px 0 0;
color: var(--asi-muted);
font: 400 15px/24px var(--asi-body);
letter-spacing: -0.3px;
}
.asi-checks {
display: grid;
gap: 13px 40px;
margin: 32px 0 0;
padding: 0;
list-style: none;
}
.asi-checks li {
display: flex;
align-items: center;
gap: 12px;
color: #000;
font: 400 16px/25.6px var(--asi-body);
letter-spacing: -0.32px;
}
.asi-checks svg {
flex: none;
width: 20px;
height: 20px;
color: var(--asi-accent);
}
.asi-btn {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
margin-top: 44px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--asi-accent);
color: #fff;
font: 400 16px/25.6px var(--asi-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--asi-ease);
}
.asi-btn:hover {
background: var(--asi-deep);
}
.asi-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--asi-accent);
transition: transform 0.3s var(--asi-ease);
}
.asi-btn-ic svg {
width: 18px;
height: 18px;
}
.asi-btn:hover .asi-btn-ic {
transform: rotate(-45deg);
}
.asi-btn:focus-visible {
outline: 2px solid var(--asi-navy);
outline-offset: 3px;
}
[data-asi-reveal="up"] {
transition:
opacity 0.8s var(--asi-ease) var(--d, 0s),
transform 0.8s var(--asi-ease) var(--d, 0s);
}
.asi[data-asi-ready] [data-asi-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.asi-w {
transition:
opacity 0.6s var(--asi-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--asi-ease) calc(var(--i) * 0.06s);
}
.asi[data-asi-ready] [data-asi-reveal="words"]:not([data-in]) .asi-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.asi-inner {
padding: 90px 40px;
}
.asi-head {
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
}
.asi-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.asi-checks {
grid-template-columns: repeat(2, auto);
justify-content: start;
}
.asi-media {
max-width: 560px;
justify-self: center;
width: 100%;
}
}
@media (min-width: 1200px) {
.asi-inner {
padding: 100px 60px 60px;
}
.asi-head {
grid-template-columns: 447px minmax(0, 1fr);
padding: 0 0 50px;
}
.asi-eyebrow {
margin-left: 37px;
}
.asi-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.asi-body {
grid-template-columns: 631px minmax(0, 1fr);
gap: 58px;
margin-top: 56px;
}
.asi-media {
max-width: none;
max-height: none;
}
.asi-copy {
padding-top: 22px;
}
.asi-stats {
gap: 20px;
margin-bottom: 44px;
}
.asi-stat {
min-height: 215px;
padding: 34px 30px;
}
.asi-years {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.asi-stat-label {
font-size: 24px;
line-height: 28.8px;
letter-spacing: -0.96px;
}
.asi-sub {
font-size: 26px;
line-height: 36.4px;
letter-spacing: -1.04px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-asi-reveal="up"],
.asi-w,
.asi-btn,
.asi-btn-ic {
transition: none;
}
.asi-btn:hover .asi-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initAboutStatsIntro(root: HTMLElement) {
if (root.dataset.asiReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.asiReady = "";
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.05, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-asi-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.asiReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-asi-root]").forEach(initAboutStatsIntro);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/AboutStatsIntro.astro =====
===== FILE (supporting): src/components/AboutStatsIntro.md =====
# About stats intro
An about-company section. Across the top, an outlined eyebrow and a big
three-line uppercase heading over a hairline. Below, a tall photo cut into a
ticket shape beside two pale stat cards (years in business; avatars, review
count and a score), a check-marked subheading, two paragraphs, a two-column
checklist and an orange pill. The star row is drawn only from a numeric score
and filled to it (4.8 of 5 fills 96%); a score that is not a number gets no
stars.
Catalog page: `/components/about-stats-intro`
## Files required
- **`AboutStatsIntro.astro`** — the component, with one small appear script.
- `public/about-stats-intro/ship-bow.webp`, `avatar-1.webp` … `avatar-3.webp` — the catalog demo's photo and avatars, passed as props. There is no default photo.
- `AboutStatsIntro.md` — this file.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"About Company"` | Generic label. `""` drops it. On its own it never renders the section. |
| `heading` | `string?` | `""` | Orange words in `*asterisks*` (several allowed). Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `image` | `{ src?, alt? }?` | none | No `src`: a labelled placeholder ("tall portrait photo"). `alt` defaults to `""`. |
| `years` | `{ value, label } \| null` | `null` | Years in business. The default is none, so no tenure is claimed. |
| `reviews` | `{ count, label, score, avatars? } \| null` | none | From a real review source only. Omit it and there is no review block — the component never supplies a rating or a review count. The catalog demo passes its block explicitly. |
| `subheading` | `string?` | `""` | Hidden when empty. |
| `paragraphs` | `string[]` | `[]` | |
| `checklist` | `string[]` | `[]` | Empty drops the list. |
| `cta` | `{ label, href } \| null` | `null` | |
| `background` | `string?` | `#ffffff` | |
| `fontStylesheet`, `class`, `id` | | | |
Nothing about a business is a default. With no heading, stats, subheading,
paragraphs, checklist or button the component renders nothing. The catalog
demo passes its heading, photo, copy, checklist, button, years and reviews
through the registry's `props`.
## Behaviour (measured)
- Pill hover: `#ff6d45` → `#052931`, arrow rotates −45°.
- Photo and copy columns rise from 80px (copy 120ms later); heading words rise
from 10px.
## Images and credits
| File | Size | Credit |
| :--- | :--- | :--- |
| `ship-bow.webp` | 848 × 1200 | Unsplash Y92tPZJLfUY, Unsplash License |
| `avatar-1.webp` | 72 × 72 | Unsplash QeF6ifnTpww, Unsplash License |
| `avatar-2.webp` | 72 × 72 | Unsplash SNvrVyUYyK0, Unsplash License |
| `avatar-3.webp` | 72 × 72 | Unsplash GODVvgKPQXI, Unsplash License |
## Approximations
- The ticket shape is a clip-path polygon approximating the reference's
rounded mask; its curves are faceted.
- The reference shows a review platform's logo beside the score; this uses a
generic star.
===== END src/components/AboutStatsIntro.md =====
===== FILE (required): public/about-stats-intro/ship-bow.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/ship-bow.webp and save it to public/about-stats-intro/ship-bow.webp]
===== FILE (required): public/about-stats-intro/avatar-1.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-1.webp and save it to public/about-stats-intro/avatar-1.webp]
===== FILE (required): public/about-stats-intro/avatar-2.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-2.webp and save it to public/about-stats-intro/avatar-2.webp]
===== FILE (required): public/about-stats-intro/avatar-3.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-3.webp and save it to public/about-stats-intro/avatar-3.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.