The 3 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: "Experience intro cards"
Library page: https://astro.baysixmedia.com/components/experience-intro-cards/
Files: 3 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/ExperienceIntroCards.astro =====
---
/**
* ExperienceIntroCards
*
* An intro band for an About page. Top row: an orange uppercase eyebrow with
* a short rule on the left; on the right a big navy heading, an orange pill
* ("Know More ↗") and a "call us" block with a phone link. Below, three cards
* of one height: a photo with a frosted label chip, a navy stat card with a
* big number that counts up, and a light card with a line of copy over a
* photo tucked into its bottom-right corner.
*
* Appear on scroll: the title row and the card grid rise 40px and fade in
* (cards staggered 0.1s); the label chip slides in from the left; the number
* counts up once. Without JS or under reduced motion everything is at rest
* and the number shows its final value.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface ExperienceLink {
label: string;
href: string;
}
export interface ExperiencePhoto {
src?: string;
alt?: string;
}
export interface ExperienceIntroCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** The orange pill. `null` (the default) drops it. */
cta?: ExperienceLink | null;
/** The "call us" block. `null` (the default) drops it; a phone with no digits and no `href` drops it too. */
contact?: {
title?: string;
phone: string;
/** Defaults to `tel:` plus the digits of `phone`. */
href?: string;
/** A 44px round portrait. Omitted: an orange phone glyph in a tinted disc. */
avatar?: ExperiencePhoto;
} | null;
/** Card 1: a photo with a small frosted label chip. No `image.src`: a neutral placeholder. */
photoCard?: { image?: ExperiencePhoto; label?: string };
/** Card 2: the navy stat card. `value` counts up from 0 on appear. Omitted: no stat card. */
statCard?: { value: number; prefix?: string; suffix?: string; caption?: string } | null;
/**
* Card 3: a line of copy over a photo in the bottom-right corner. The
* line is only ever the caller's: omitted, or a card passed without
* `text`, and the card has no line — never a ranking nobody earned.
*/
featureCard?: { text?: string; image?: ExperiencePhoto };
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ExperienceIntroCardsProps {}
/*
* The copy, the phone, the figure and the photos are the caller's: nothing
* about a business is filled in here. The catalog demo passes its sample
* content through the registry's `props`.
*/
const {
eyebrow = "",
heading = "",
headingLevel = "h2",
cta = null,
contact = null,
photoCard = {},
statCard = null,
featureCard = {},
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const photo = (given?: ExperiencePhoto) => ({ src: given?.src ?? "", alt: given?.alt ?? "" });
const img1 = photo(photoCard.image);
const img3 = photo(featureCard.image);
const chip = photoCard.label ?? "";
const featureText = featureCard.text ?? "";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const phoneNumber = typeof contact?.phone === "string" ? contact.phone.trim() : "";
const passedHref = contact?.href ? safeHref(contact.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = contact && callHref ? { ...contact, phone: phoneNumber, href: callHref } : null;
const Heading = headingLevel;
const stat =
statCard && Number.isFinite(statCard.value)
? { ...statCard, value: Math.max(0, Math.round(statCard.value)) }
: null;
const statValue = stat ? stat.value : 0;
const statText = stat ? `${stat.prefix ?? ""}${statValue.toLocaleString("en-US")}${stat.suffix ?? ""}` : "";
/* Nothing to say → nothing rendered: an eyebrow alone is not a section. */
const hasContent = Boolean(heading || cta || call || stat || featureText || chip || img1.src || img3.src);
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class:list={["eic", className]} id={id} data-eic-root>
<div class="eic-inner">
{(eyebrow || heading || cta || call) && (
<div class="eic-top" data-eic-appear>
{
eyebrow && (
<p class="eic-eyebrow">
<span>{eyebrow}</span>
<span class="eic-rule" aria-hidden="true" />
</p>
)
}
{(heading || cta || call) && (
<div class="eic-intro">
{heading && <Heading class="eic-heading">{heading}</Heading>}
{
(cta || call) && (
<div class="eic-actions">
{cta && (
<a class="eic-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="eic-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)}
{call && (
<div class="eic-call">
{call.avatar?.src ? (
<img class="eic-avatar" src={call.avatar.src} alt={call.avatar.alt ?? ""} width="44" height="44" loading="lazy" decoding="async" />
) : (
<span class="eic-avatar eic-avatar--icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z" />
</svg>
</span>
)}
<div class="eic-call-text">
{call.title && <p class="eic-call-title">{call.title}</p>}
<p class="eic-call-line">
<a class="eic-phone" href={call.href}>{call.phone}</a>
</p>
</div>
</div>
)}
</div>
)
}
</div>
)}
</div>
)}
<div class="eic-grid">
<div class="eic-card eic-card--photo" data-eic-appear style="--eic-i: 0">
{
img1.src ? (
<img class="eic-cover" src={img1.src} alt={img1.alt} width="796" height="652" loading="lazy" decoding="async" />
) : (
<div class="eic-ph" role="img" aria-label="Image placeholder: landscape photo">
<div class="eic-ph-text" aria-hidden="true">
<span class="eic-ph-label">Image</span>
<span class="eic-ph-hint">Landscape photo</span>
<span class="eic-ph-spec">~1.22:1 · 796 × 652 px</span>
</div>
</div>
)
}
{chip && <p class="eic-chip">{chip}</p>}
</div>
{stat && (
<div class="eic-card eic-card--stat" data-eic-appear style="--eic-i: 1">
<svg class="eic-waves" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-width="1">
<path d="M120 -10C210 60 250 140 410 170" />
<path d="M140 -10C225 65 265 150 410 190" />
<path d="M160 -10C240 70 280 160 410 210" />
<path d="M180 -10C255 75 295 170 410 230" />
<path d="M200 -10C270 80 310 180 410 250" />
<path d="M220 -10C285 85 325 190 410 270" />
<path d="M240 -10C300 90 340 200 410 290" />
<path d="M260 -10C315 95 355 210 410 310" />
</g>
</svg>
<p class="eic-stat">
<span aria-hidden="true">
{stat.prefix}<span data-eic-count data-target={statValue}>{statValue.toLocaleString("en-US")}</span>{stat.suffix}
</span>
<span class="eic-sr">{statText}</span>
</p>
<div class="eic-stat-foot">
<span class="eic-stat-rule" aria-hidden="true" />
{stat.caption && <p class="eic-stat-caption">{stat.caption}</p>}
</div>
</div>
)}
<div class="eic-card eic-card--feature" data-eic-appear style="--eic-i: 2">
{featureText && <p class="eic-feature-text">{featureText}</p>}
<div class="eic-feature-media">
{
img3.src ? (
<img class="eic-cover" src={img3.src} alt={img3.alt} width="740" height="400" loading="lazy" decoding="async" />
) : (
<div class="eic-ph" role="img" aria-label="Image placeholder: wide landscape photo">
<div class="eic-ph-text" aria-hidden="true">
<span class="eic-ph-label">Image</span>
<span class="eic-ph-hint">Wide landscape photo</span>
<span class="eic-ph-spec">~1.85:1 · 740 × 400 px</span>
</div>
</div>
)
}
</div>
</div>
</div>
</div>
</section>
)}
<style>
.eic {
--eic-navy: var(--tg-navy, #020e28);
--eic-orange: var(--tg-orange, #fd5523);
--eic-muted: var(--tg-muted, #61697d);
--eic-surface: var(--tg-surface, #ecf0f1);
--eic-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--eic-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--eic-ease: cubic-bezier(0.22, 1, 0.36, 1);
--eic-link: 0.4s cubic-bezier(0.44, 0, 0.56, 1);
box-sizing: border-box;
padding: 60px 0;
background: #fff;
color: var(--eic-navy);
font-family: var(--eic-body);
}
.eic *,
.eic *::before,
.eic *::after {
box-sizing: border-box;
}
.eic p {
margin: 0;
}
.eic-inner {
display: flex;
flex-direction: column;
gap: 40px;
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
}
/* ---- title row ---- */
.eic-top {
display: grid;
gap: 16px;
}
.eic .eic-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
font: 600 16px/1 var(--eic-display);
text-transform: uppercase;
color: var(--eic-orange);
}
.eic-rule {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.eic-intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 28px;
min-width: 0;
}
.eic-heading {
margin: 0;
font: 600 32px/1.2 var(--eic-display);
color: var(--eic-navy);
overflow-wrap: anywhere;
}
.eic-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.eic-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 18px 26px;
border-radius: 100px;
background: var(--eic-orange);
color: #fff;
font: 600 14px/1 var(--eic-body);
text-decoration: none;
transition: background-color 0.35s ease-out;
}
.eic-btn-arrow {
flex: none;
transition: transform 0.35s ease-out;
}
.eic-btn:hover {
background: var(--eic-navy);
}
.eic-btn:hover .eic-btn-arrow {
transform: rotate(45deg);
}
.eic-btn:focus-visible {
outline: 2px solid var(--eic-navy);
outline-offset: 3px;
}
.eic-call {
display: flex;
align-items: center;
gap: 16px;
}
.eic-avatar {
flex: none;
display: block;
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
}
.eic-avatar--icon {
display: grid;
place-items: center;
background: rgb(253 85 35 / 0.1);
color: var(--eic-orange);
}
.eic-call-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.eic .eic-call-title,
.eic .eic-call-line {
font: 600 16px/1.2 var(--eic-display);
color: var(--eic-navy);
}
.eic-phone {
display: inline-block;
padding: 4px 0;
margin: -4px 0;
color: var(--eic-orange);
text-decoration: none;
transition: color var(--eic-link);
}
.eic-phone:hover {
color: var(--eic-navy);
}
.eic-phone:focus-visible {
outline: 2px solid var(--eic-orange);
outline-offset: 2px;
border-radius: 2px;
}
/* ---- cards ---- */
.eic-grid {
display: grid;
gap: 24px;
}
.eic-card {
position: relative;
min-width: 0;
border-radius: 30px;
overflow: hidden;
}
.eic-card--photo {
aspect-ratio: 398 / 326;
}
.eic-cover {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eic .eic-chip {
position: absolute;
left: 24px;
bottom: 24px;
padding: 12px 18px;
border-radius: 18px;
background: rgb(255 255 255 / 0.25);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font: 600 16px/1 var(--eic-display);
color: #fff;
}
.eic-card--stat {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 40px;
padding: 24px;
background: var(--eic-navy);
color: #fff;
}
.eic-waves {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: rgb(255 255 255 / 0.07);
pointer-events: none;
}
.eic .eic-stat {
position: relative;
font: 600 64px/1.2 var(--eic-display);
font-variant-numeric: tabular-nums;
color: #fff;
}
.eic-stat-foot {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.eic-stat-rule {
display: block;
width: 100%;
height: 3px;
background: rgb(255 255 255 / 0.2);
}
.eic .eic-stat-caption {
max-width: 340px;
font: 400 16px/21px var(--eic-body);
color: #fff;
}
.eic-card--feature {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 28px;
background: var(--eic-surface);
}
.eic .eic-feature-text {
padding: 24px 24px 0;
font: 600 22px/1.42 var(--eic-display);
color: var(--eic-navy);
}
.eic-feature-media {
position: relative;
height: 180px;
margin-left: 24px;
border-radius: 30px 0 0 0;
overflow: hidden;
}
.eic-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- placeholder for src: "" ---- */
.eic-ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.eic-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.eic-ph-label {
font: 500 12px/1 var(--eic-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.eic-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--eic-body);
}
.eic-ph-spec {
font: 400 12px/1.2 var(--eic-body);
}
/* ---- appear: hidden start only once the script marks the root ready ---- */
[data-eic-appear] {
transition:
opacity 0.8s var(--eic-ease),
transform 0.8s var(--eic-ease);
transition-delay: calc(var(--eic-i, 0) * 0.1s);
}
.eic-chip {
transition:
opacity 0.7s var(--eic-ease) 0.35s,
transform 0.7s var(--eic-ease) 0.35s;
}
.eic[data-eic-ready] [data-eic-appear]:not([data-in]) {
opacity: 0;
transform: translateY(40px);
}
.eic[data-eic-ready] [data-eic-appear]:not([data-in]) .eic-chip {
opacity: 0;
transform: translateX(-30px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.eic {
padding: 90px 0;
}
.eic-inner {
gap: 50px;
}
.eic-heading {
font-size: 40px;
}
.eic-actions {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 35px;
}
.eic-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 30px;
}
.eic-card--photo {
aspect-ratio: auto;
min-height: 326px;
}
.eic-card--feature {
grid-column: 1 / -1;
min-height: 326px;
}
.eic-card--stat {
padding: 30px;
}
.eic .eic-stat {
font-size: 74px;
}
.eic .eic-feature-text {
padding: 30px 30px 0;
font-size: 24px;
line-height: 34px;
}
.eic-feature-media {
height: 200px;
margin-left: 28px;
}
.eic .eic-chip {
left: 31px;
bottom: 31px;
}
}
/* ---- desktop: eyebrow left, heading column right, three cards ---- */
@media (min-width: 1200px) {
.eic {
padding: 120px 0;
}
.eic-inner {
gap: 56px;
}
.eic-top {
grid-template-columns: minmax(0, 419fr) minmax(0, 834fr);
align-items: start;
gap: 0;
}
.eic .eic-eyebrow {
font-size: 18px;
}
.eic-intro {
gap: 40px;
}
.eic-heading {
font-size: 48px;
line-height: 57.6px;
}
.eic-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 326px;
}
.eic-card--feature {
grid-column: auto;
}
.eic .eic-stat-caption {
max-width: 338px;
}
}
@media (prefers-reduced-motion: reduce) {
.eic-btn:hover .eic-btn-arrow {
transform: none;
}
[data-eic-appear],
.eic-chip {
transition: none;
}
}
@media (forced-colors: active) {
.eic-card {
border: 1px solid CanvasText;
}
.eic-btn,
.eic .eic-chip {
border: 1px solid ButtonText;
}
.eic-stat-rule,
.eic-rule {
background: CanvasText;
}
}
</style>
<script>
/*
* Appear on scroll plus the stat count-up. One IntersectionObserver per
* instance, watching the title row and the three cards; each gets
* `data-in` once and is unobserved. The count-up runs once when the stat
* card arrives. Every observer and frame is released on astro:before-swap.
* Under reduced motion (or without IntersectionObserver) nothing is ever
* hidden and the number simply shows its final value.
*/
function initExperienceIntroCards(root: HTMLElement) {
if (root.dataset.eicReady !== undefined) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!("IntersectionObserver" in window)) return;
const controller = new AbortController();
const { signal } = controller;
let frame = 0;
const counter = root.querySelector<HTMLElement>("[data-eic-count]");
const target = Number(counter?.dataset.target ?? 0);
const format = (n: number) => Math.round(n).toLocaleString("en-US");
const countUp = () => {
if (!counter || !Number.isFinite(target) || target <= 0) return;
const start = performance.now();
const duration = 2200;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
counter.textContent = format(target * (1 - Math.pow(1 - t, 3)));
if (t < 1 && !signal.aborted) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
root.dataset.eicReady = "";
if (counter) counter.textContent = "0";
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target as HTMLElement;
el.dataset.in = "";
observer.unobserve(el);
if (counter && el.contains(counter)) countUp();
}
},
{ threshold: 0.15 },
);
root.querySelectorAll<HTMLElement>("[data-eic-appear]").forEach((el) => observer.observe(el));
signal.addEventListener("abort", () => {
observer.disconnect();
cancelAnimationFrame(frame);
if (counter) counter.textContent = format(target);
});
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.eicReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-eic-root]").forEach(initExperienceIntroCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ExperienceIntroCards.astro =====
===== FILE (supporting): src/components/ExperienceIntroCards.md =====
# Experience intro cards
An About-page intro band: eyebrow on the left, a big heading on the right
with an orange pill and a "call us" phone link, and three equal cards below —
a photo with a frosted label chip, a navy stat card that counts up, and a
light card with a line of copy over a photo tucked into its corner.
Catalog page: `/components/experience-intro-cards`
## Purpose
The first section under an inner-page banner on an About page: one sentence
of positioning, one call to action, one number, one proof point. Part of the
Northpass (Transigo-style) family; reads the shared `--tg-*` tokens.
## Files required
**`ExperienceIntroCards.astro`** — the whole component. No imports, no
packages, no global CSS.
`ExperienceIntroCards.md` — this file.
`public/experience-intro-cards/truck-sunset.webp` (796×652) and
`mountain-road.webp` (740×400) — the catalog demo photos, passed through
`photoCard.image` and `featureCard.image`. There are no default photos.
## Basic usage
```astro
---
import ExperienceIntroCards from "../components/ExperienceIntroCards.astro";
---
<ExperienceIntroCards
eyebrow="Our Track Record"
heading="Freight Moved On Time, Handled With Care, Priced Up Front."
cta={{ label: "Know More", href: "/about" }}
contact={{ title: "Call Us For Any Inquiry", phone: "+1 (510) 555-0148" }}
photoCard={{ image: { src: "/experience-intro-cards/truck-sunset.webp", alt: "A white semi truck on a motorway at sunset" }, label: "Road Freight" }}
statCard={{ value: 520, suffix: "+", caption: "Drivers and dispatchers on staff" }}
featureCard={{ image: { src: "/experience-intro-cards/mountain-road.webp", alt: "A road winding through a forested mountain valley" } }}
/>
```
Every value above is demo content. Nothing about a business is a default:
with no heading, button, dialable phone, stat, card line, chip or photo the
component renders nothing.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Orange uppercase label with a 40px rule after it. Hidden when empty; an eyebrow alone does not render the section. |
| `heading` | `string?` | `""` | The big heading. Written in title case; no `text-transform`. Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `cta` | `{ label, href } \| null` | `null` | The orange pill. `null` drops it. |
| `contact` | `{ title?, phone, href?, avatar? } \| null` | `null` | The call-us block. `href` defaults to `tel:` + a leading `+` and the digits of `phone`; a safe explicit `href` is honoured; no digits and no `href` drops the block. `avatar: { src, alt }` shows a 44px round portrait; without it, an orange phone glyph in a tinted disc. |
| `photoCard` | `{ image?, label? }` | `{}` | Card 1. No `image.src`: a neutral placeholder. No `label`: no chip. |
| `statCard` | `{ value, prefix?, suffix?, caption? } \| null` | `null` | Card 2. `value` is an integer and is what the count-up ends on. Omitted: no stat card (the grid keeps its columns, so that slot stays empty). |
| `featureCard` | `{ text?, image? }` | `{}` — no line, no photo | Card 3. Pass your own and the demo line is not filled in under it: omit `text` and the card has no line. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips the Google Fonts link. |
| `class`, `id` | `string?` | — | |
Photo slots are `{ src?, alt? }`. A `src` without an `alt` makes the image
decorative (`alt=""`); no `src` renders a labelled striped placeholder with a
neutral hint ("Landscape photo") and its size.
## Behaviour
| Element | Effect | Measured / chosen |
| :--- | :--- | :--- |
| Orange pill hover | background `#fd5523` → `#020e28`, ↗ arrow rotates 45° | as measured; 0.35s ease-out |
| Phone link hover | orange → navy | `color 0.4s cubic-bezier(0.44, 0, 0.56, 1)`, from the dump |
| Title row appear | fades up from `translateY(40px)` | as measured, 0.8s |
| Cards appear | each fades up from `translateY(40px)`, staggered 0.1s | as measured |
| Label chip appear | fades in from `translateX(-30px)` 0.35s after its card | see Approximations |
| Stat count-up | 0 → `value` over 2.2s, ease-out cubic, once | see Approximations |
One IntersectionObserver per instance (threshold 0.15) sets `data-in` on each
element once. The hidden start poses exist only while the root carries
`data-eic-ready`, which the script sets. Everything is released on
`astro:before-swap`; init runs on load and on `astro:page-load` and is
idempotent.
## Accessibility
- Real heading, real links. The chip and the stat caption are plain text.
- The animated digits are `aria-hidden`; a visually hidden copy carries the
final value ("520+"), so a screen reader never hears the count mid-flight.
- Visible focus rings on the pill (navy) and the phone link (orange).
- Decorative waves in the stat card and the phone glyph are `aria-hidden`.
- Under `forced-colors: active` the cards, pill and chip get real borders.
## Reduced motion / without JavaScript
Nothing starts hidden, the number shows its final value from the HTML, and
the pill arrow does not rotate (the colour change stays).
## Responsive
| Width | Layout |
| :--- | :--- |
| ≥ 1200 | eyebrow column 419 / heading column 834 (of 1253); three 326px-tall cards, 30px gaps; heading 48/57.6 |
| 810–1199 | eyebrow above heading (40px); photo and stat cards side by side, feature card full width |
| < 810 | one column; heading 32px; pill above the call block; cards stacked, photo card at the measured 398:326 ratio |
## Images and credits
Both photos are from Unsplash (Unsplash License — free for commercial use, no
attribution required), optimised to WebP:
- `truck-sunset.webp` — Unsplash `ucuOscdCaO4`
- `mountain-road.webp` — Unsplash `7zWoum7qzsg`
## Approximations
- **Label chip slide.** The dump's `transform` on the chip (−60.55, −17.80) is
exactly half its own width and height — a `translate(-50%, -50%)`
positioning transform, not an appear offset. No slide distance was
measurable, so the chip fades in from −30px after its card. Its rest
position (31px from the card's left and bottom) is measured.
- **Count-up.** Three captures showed 504, 510 and 516 at different moments,
so the reference counts up; its start value, duration and easing were not
measurable. 0 → value over 2.2s ease-out is a choice.
- **Chip blur.** The chip is `rgba(255,255,255,.25)` as measured; the
`backdrop-filter: blur(6px)` is inferred from the screenshot.
- **Stat card background.** The reference uses an image of faint curved lines;
redrawn here as an inline SVG.
- **Avatar.** The reference shows a person's photo; the default here is a
phone glyph (pass `avatar` for a portrait).
- Tablet layout is inferred (no tablet capture).
===== END src/components/ExperienceIntroCards.md =====
===== FILE (required): public/experience-intro-cards/truck-sunset.webp =====
[binary asset — download https://astro.baysixmedia.com/experience-intro-cards/truck-sunset.webp and save it to public/experience-intro-cards/truck-sunset.webp]
===== FILE (required): public/experience-intro-cards/mountain-road.webp =====
[binary asset — download https://astro.baysixmedia.com/experience-intro-cards/mountain-road.webp and save it to public/experience-intro-cards/mountain-road.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.