Component library
Back to catalog

Experience intro cards

An About-page intro: orange eyebrow left, big navy heading right with an orange Know More pill and a call-us phone link, over three cards — a photo with a frosted label chip, a navy stat card whose number counts up, and a light card with a line of copy over a corner photo.

aboutintrostatscardscount-upappear

Open preview in a new tab

Use this component

Three ways to get Experience intro 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 3 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 Experience intro cards from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/experience-intro-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 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.

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
“Experience intro cards” from my Astro Component Library:
https://astro.baysixmedia.com/components/experience-intro-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
Ships script
Profile
core only
Installation files
3 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
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 fixtureOpen the ClientRouter fixture

src/components/library/ExperienceIntroCards.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/experience-intro-cards/truck-sunset.webp, public/experience-intro-cards/mountain-road.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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>