Component library
Back to catalog

Service photo cards

A services section: eyebrow, heading and an orange pill over three tall photo cards, each one link with a white panel. On hover the photo zooms, the icon tile turns orange, the arrow slides through and a description opens in the panel.

servicescardsphotohoverappear

Open preview in a new tab

Use this component

Three ways to get Service photo 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 4 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 Service photo cards from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/service-photo-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 4 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Service photo cards"
Library page: https://astro.baysixmedia.com/components/service-photo-cards/
Files: 4 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/ServicePhotoCards.astro =====
---
/**
 * ServicePhotoCards
 *
 * A services section: an orange eyebrow and a heading on the left with an
 * orange pill link on the right, over three tall photo cards. Each card is a
 * single link: a photo with a white panel pinned to its bottom holding an
 * icon tile that pokes out above it, the service name and a pink arrow tile.
 *
 * Desktop hover (and keyboard focus): the photo zooms to 1.1, the icon tile
 * fills orange, the arrow slides through, and a short description opens in
 * the panel, which grows upward and lifts the icon tile with it. On touch
 * screens and below 1200px the description is always shown, as the
 * reference's phone layout does.
 *
 * On scroll the header and the cards rise 40px into place, cards 0.1s apart.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type ServicePhotoIcon = "ship" | "truck" | "train" | "plane" | "warehouse";

export interface ServicePhotoCard {
	title: string;
	description?: string;
	href: string;
	icon?: ServicePhotoIcon;
	/** `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
}

export interface ServicePhotoCardsProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** The orange pill beside the heading. `null` drops it. */
	cta?: { label: string; href: string } | null;
	cards?: ServicePhotoCard[];
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ServicePhotoCardsProps {}

const {
	eyebrow = "Our services",
	heading = "",
	headingLevel = "h2",
	cta = null,
	cards = [],
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* Each card is one link: it needs a title and an href, or it is dropped rather
   than drawn half-empty. A pill needs a label and an href. No cards, no section:
   the eyebrow and heading alone never render. */
const list = cards.filter((card) => card?.title && card?.href);
const pill = cta?.label && cta?.href ? cta : null;

const uid = id ?? `spc-${Math.random().toString(36).slice(2, 8)}`;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* Measured card: 388 × 430 at desktop; the files are 776 × 860. */
const IMAGE_SPEC = "~0.9:1 · 776 × 860 px";
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class:list={["spc", className]} id={id} style={`--spc-bg: ${background};`} data-spc-root>
	<div class="spc-inner">
		<div class="spc-head" data-spc-rise>
			<div class="spc-titles">
				{
					eyebrow && (
						<p class="spc-eyebrow">
							{eyebrow}
							<span class="spc-rule" aria-hidden="true" />
						</p>
					)
				}
				{heading && <Heading class="spc-heading">{heading}</Heading>}
			</div>
			{
				pill && (
					<a class="spc-btn" href={safeHref(pill.href)}>
						{pill.label}
						<svg class="spc-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M2 10 10 2M3.5 2H10v6.5" />
						</svg>
					</a>
				)
			}
		</div>

		<ul class="spc-grid" role="list">
			{
				list.map((card, index) => {
					const titleId = `${uid}-t${index}`;
					const descId = `${uid}-d${index}`;
					const img = card.image ?? {};
					return (
						<li class="spc-item" data-spc-rise style={`--i: ${index};`}>
							<a
								class="spc-card"
								href={safeHref(card.href)}
								aria-labelledby={titleId}
								aria-describedby={card.description ? descId : undefined}
							>
								<span class="spc-media" aria-hidden={img.src && img.alt ? undefined : "true"}>
									{img.src ? (
										<img src={img.src} alt={img.alt ?? ""} width="776" height="860" loading="lazy" decoding="async" />
									) : (
										<span class="spc-ph">
											<span class="spc-ph-label">Image</span>
											<span class="spc-ph-hint">{card.title}, tall photo</span>
											<span class="spc-ph-spec">{IMAGE_SPEC}</span>
										</span>
									)}
								</span>
								<span class="spc-body">
									<span class="spc-icon" aria-hidden="true">
										<span class="spc-icon-hover" />
										<svg viewBox="0 0 40 40" width="40" height="40" focusable="false">
											{card.icon === "truck" ? (
												<g>
													<rect x="10" y="5" width="20" height="26" rx="3" />
													<path d="M10 13h20M10 22h20M14 9h12" />
													<circle cx="15" cy="26.5" r="1.5" />
													<circle cx="25" cy="26.5" r="1.5" />
													<path d="M13 31v4M27 31v4M7 14v6M33 14v6" />
												</g>
											) : card.icon === "train" ? (
												<g>
													<path d="M12 5h16a3 3 0 0 1 3 3v19a3 3 0 0 1-3 3H12a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z" />
													<path d="M9 17h22M15 9h10" />
													<rect x="13" y="11" width="14" height="6" />
													<circle cx="14" cy="24" r="1.5" />
													<circle cx="26" cy="24" r="1.5" />
													<path d="m13 30-4 6M27 30l4 6M11 33h18M4 36h32" />
												</g>
											) : card.icon === "plane" ? (
												<g>
													<path d="M4 22h12l8-12h4l-4 12h8l3-4h3l-1.5 6 1.5 6h-3l-3-4h-8l4 12h-4l-8-12H4" transform="translate(0 -4)" />
												</g>
											) : card.icon === "warehouse" ? (
												<g>
													<path d="M4 15 20 6l16 9v19H4Z" />
													<path d="M10 34V20h20v14M10 25h20M10 30h20" />
												</g>
											) : (
												<g>
													<path d="M4 27h32l-4 7H9Z" />
													<path d="M9 27v-8h22v8" />
													<path d="M13 19v-6h10v6M18 13V7h4M26 23h1M14 23h1M20 23h1" />
													<path d="M3 37c2.5 0 2.5-1.5 5-1.5s2.5 1.5 5 1.5 2.5-1.5 5-1.5 2.5 1.5 5 1.5 2.5-1.5 5-1.5 2.5 1.5 5 1.5 2.5-1.5 4-1.5" />
												</g>
											)}
										</svg>
									</span>
									<span class="spc-text">
										<span class="spc-title-row">
											<span class="spc-title" id={titleId}>{card.title}</span>
											<span class="spc-arrow" aria-hidden="true">
												<svg class="spc-arrow-a" viewBox="0 0 18 16" width="18" height="16" focusable="false">
													<path d="M1.5 8h15M10 1.5 16.5 8 10 14.5" />
												</svg>
												<svg class="spc-arrow-b" viewBox="0 0 18 16" width="18" height="16" focusable="false">
													<path d="M1.5 8h15M10 1.5 16.5 8 10 14.5" />
												</svg>
											</span>
										</span>
										{card.description && (
											<span class="spc-desc-wrap">
												<span class="spc-desc" id={descId}>{card.description}</span>
											</span>
										)}
									</span>
								</span>
							</a>
						</li>
					);
				})
			}
		</ul>
	</div>
</section>
)}

<style>
	.spc {
		--spc-navy: var(--tg-navy, #020e28);
		--spc-orange: var(--tg-orange, #fd5523);
		--spc-muted: var(--tg-muted, #61697d);
		--spc-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--spc-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--spc-ease: cubic-bezier(0.25, 0.1, 0.25, 1);

		box-sizing: border-box;
		padding: 80px 20px;
		background: var(--spc-bg, #fff);
		color: var(--spc-navy);
	}

	.spc *,
	.spc *::before,
	.spc *::after {
		box-sizing: border-box;
	}

	.spc-inner {
		display: flex;
		flex-direction: column;
		gap: 30px;
		max-width: 1224px;
		margin: 0 auto;
	}

	/* ---- header ---- */
	.spc-head {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
	}

	.spc-titles {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		max-width: 645px;
	}

	.spc .spc-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--spc-display);
		text-transform: uppercase;
		color: var(--spc-orange);
	}

	.spc-rule {
		display: none;
		width: 40px;
		height: 1px;
		background: var(--spc-orange);
	}

	.spc-heading {
		margin: 0;
		font: 600 32px/1.2 var(--spc-display);
		letter-spacing: normal;
		text-transform: capitalize;
		color: var(--spc-navy);
	}

	.spc .spc-btn {
		display: inline-flex;
		flex: none;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		padding: 18px 24px;
		border-radius: 100px;
		background: var(--spc-orange);
		font: 600 14px/12.46px var(--spc-body);
		text-transform: capitalize;
		color: #fff;
		text-decoration: none;
		transition: background-color 0.3s var(--spc-ease);
	}

	.spc-btn-arrow {
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.3s var(--spc-ease);
	}

	.spc .spc-btn:hover {
		background: var(--spc-navy);
	}

	.spc .spc-btn:hover .spc-btn-arrow {
		transform: rotate(45deg);
	}

	.spc .spc-btn:focus-visible {
		outline: 2px solid var(--spc-navy);
		outline-offset: 3px;
	}

	/* ---- cards ---- */
	.spc .spc-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.spc-card {
		position: relative;
		isolation: isolate;
		display: flex;
		flex-direction: column;
		justify-content: flex-end;
		aspect-ratio: 350 / 335;
		padding: 12px;
		border-radius: 20px;
		overflow: hidden;
		color: var(--spc-navy);
		text-decoration: none;
		background: #dfe5e7;
	}

	.spc-card:focus-visible {
		outline: 3px solid var(--spc-orange);
		outline-offset: 4px;
	}

	.spc-media {
		position: absolute;
		inset: 0;
		z-index: -1;
		display: block;
	}

	.spc-media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		transition: transform 0.6s var(--spc-ease);
	}

	.spc-ph {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: 8px;
		width: 100%;
		height: 100%;
		padding: 16px 16px 45%;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.spc-ph-label {
		font: 600 12px/1 var(--spc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.spc-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--spc-body);
	}

	.spc-ph-spec {
		font: 400 12px/1.2 var(--spc-body);
	}

	.spc-body {
		position: relative;
		display: block;
		margin-top: 60px;
		padding: 40px 12px 16px;
		border-radius: 10px;
		background: #fff;
	}

	.spc-icon {
		position: absolute;
		top: -39px;
		left: 12px;
		display: grid;
		place-items: center;
		width: 60px;
		height: 60px;
		border-radius: 5px;
		background: #fff;
		box-shadow:
			rgb(12 12 13 / 0.05) 0 1px 4px 0,
			rgb(12 12 13 / 0.1) 0 1px 4px 0;
		color: var(--spc-navy);
		transition: color 0.4s var(--spc-ease);
	}

	.spc-icon-hover {
		position: absolute;
		inset: 0;
		border-radius: 5px;
		background: var(--spc-orange);
		opacity: 0;
		transition: opacity 0.4s var(--spc-ease);
	}

	.spc-icon svg {
		position: relative;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.5;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.spc-text {
		display: block;
	}

	.spc-title-row {
		display: flex;
		justify-content: space-between;
		align-items: flex-end;
		gap: 16px;
	}

	.spc-title {
		max-width: 180px;
		font: 700 20px/24px var(--spc-display);
		color: var(--spc-navy);
	}

	.spc-arrow {
		position: relative;
		flex: none;
		display: block;
		width: 40px;
		height: 40px;
		border-radius: 10px;
		background: #ffe0e0;
		overflow: hidden;
	}

	.spc-arrow svg {
		position: absolute;
		top: 12px;
		left: 11px;
		fill: none;
		stroke: var(--spc-orange);
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.4s var(--spc-ease);
	}

	.spc-arrow-b {
		transform: translateX(-30px);
	}

	.spc-desc-wrap {
		display: grid;
		grid-template-rows: 1fr;
		margin-top: 12px;
		transition:
			grid-template-rows 0.4s var(--spc-ease),
			margin-top 0.4s var(--spc-ease),
			opacity 0.4s var(--spc-ease);
	}

	.spc-desc {
		display: block;
		min-height: 0;
		overflow: hidden;
		font: 400 15px/20px var(--spc-body);
		color: var(--spc-muted);
	}

	/* ---- hover / focus ---- */
	.spc-card:hover .spc-media img,
	.spc-card:focus-visible .spc-media img {
		transform: scale(1.1);
	}

	.spc-card:hover .spc-icon,
	.spc-card:focus-visible .spc-icon {
		color: #fff;
	}

	.spc-card:hover .spc-icon-hover,
	.spc-card:focus-visible .spc-icon-hover {
		opacity: 1;
	}

	.spc-card:hover .spc-arrow-a,
	.spc-card:focus-visible .spc-arrow-a {
		transform: translateX(30px);
	}

	.spc-card:hover .spc-arrow-b,
	.spc-card:focus-visible .spc-arrow-b {
		transform: translateX(0);
	}

	/* ---- appear ---- */
	[data-spc-rise] {
		transition:
			opacity 0.8s var(--spc-ease) calc(var(--i, 0) * 0.1s),
			transform 0.8s var(--spc-ease) calc(var(--i, 0) * 0.1s);
	}

	.spc[data-spc-ready]:not([data-in]) [data-spc-rise] {
		opacity: 0;
		transform: translateY(40px);
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.spc {
			padding: 100px 30px;
		}

		.spc-inner {
			gap: 50px;
		}

		.spc-head {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-end;
		}

		.spc .spc-eyebrow {
			font-size: 18px;
			line-height: 12.6px;
		}

		.spc-rule {
			display: block;
		}

		.spc-heading {
			font-size: 40px;
		}

		.spc .spc-btn {
			min-height: 56px;
			padding: 22px 30px;
		}

		.spc .spc-grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 30px;
		}

		.spc-card {
			aspect-ratio: 388 / 430;
			padding: 20px;
		}

		.spc-body {
			padding: 40px 30px 20px;
		}

		.spc-icon {
			left: 30px;
		}

		.spc-title {
			max-width: 150px;
			font-size: 22px;
			line-height: 26px;
		}
	}

	/* ---- desktop: three 388 × 430 cards ---- */
	@media (min-width: 1200px) {
		.spc {
			padding: 120px 20px;
		}

		.spc-inner {
			gap: 60px;
		}

		.spc-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.spc .spc-grid {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.spc-title {
			max-width: 140px;
		}
	}

	/* Desktop with a real pointer: the description stays folded until the
	   card is hovered or focused, and the panel grows upward to show it. */
	@media (min-width: 1200px) and (hover: hover) and (pointer: fine) {
		.spc-desc-wrap {
			grid-template-rows: 0fr;
			margin-top: 0;
			opacity: 0;
		}

		.spc-card:hover .spc-desc-wrap,
		.spc-card:focus-visible .spc-desc-wrap {
			grid-template-rows: 1fr;
			margin-top: 20px;
			opacity: 1;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		[data-spc-rise],
		.spc-media img,
		.spc-arrow svg,
		.spc-desc-wrap,
		.spc-btn-arrow {
			transition: none;
		}

		.spc-card:hover .spc-media img,
		.spc-card:focus-visible .spc-media img,
		.spc .spc-btn:hover .spc-btn-arrow {
			transform: none;
		}

		.spc[data-spc-ready]:not([data-in]) [data-spc-rise] {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.spc-body,
		.spc .spc-btn {
			border: 1px solid CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll. One IntersectionObserver per instance, scoped to its
	 * root, torn down on astro:before-swap; sets `data-in` once. The hidden
	 * start pose applies only after the root is marked ready, so no-JS and
	 * reduced motion show everything at rest. Hovers are pure CSS.
	 */
	function initServicePhotoCards(root: HTMLElement) {
		if (root.dataset.spcReady === "") return;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.spcReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.spcReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-spc-root]").forEach(initServicePhotoCards);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/ServicePhotoCards.astro =====

===== FILE (supporting): src/components/ServicePhotoCards.md =====
# Service photo cards

A services section: an orange eyebrow and heading on the left, an orange
"View All Services" pill on the right, and three tall photo cards below.
Each card is one link: a photo with a white panel pinned to its bottom — an
icon tile poking out of the panel's top edge, the service name and a pink
arrow tile. On hover the photo zooms, the icon tile turns orange, the arrow
slides through and a short description opens in the panel.

Catalog page: `/components/service-photo-cards`

## Files required

**`ServicePhotoCards.astro`** — the whole component, icons inline, one small
script for the appear. No imports, no npm packages.

`ServicePhotoCards.md` — this file.

`public/service-photo-cards/ocean.webp`, `truck.webp`, `rail.webp` — the catalog
demo's card photos, passed as props (not defaults).

## Basic usage

```astro
---
import ServicePhotoCards from "../components/ServicePhotoCards.astro";
---

<ServicePhotoCards
  heading="Freight & Logistics Services"
  cta={{ label: "View All Services", href: "/services" }}
  cards={[
    {
      title: "Ocean Freight",
      description: "Full and part container loads…",
      href: "/services/ocean-freight",
      icon: "ship",
      image: { src: "/img/ocean.webp" },
    },
    // …
  ]}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Our services"` | `""` drops it. |
| `heading` | `string?` | `""` | `""` drops it. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Card titles are not headings; they name the links. |
| `cta` | `{ label, href } \| null?` | `null` | The pill. Needs both `label` and `href`; `null` drops it. |
| `cards` | `ServicePhotoCard[]?` | `[]` | Any count; three per row on desktop. A card without a `title` or `href` is dropped. With no cards the component renders nothing — not the eyebrow, heading, pill or font `<link>`. The catalog demo passes its three cards as props. |
| `background` | `string?` | `#ffffff` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `class`, `id` | `string?` | — | `id` also seeds the title/description ids. |

```ts
interface ServicePhotoCard {
  title: string;         // keep to two short words per line, it wraps at ~140px
  description?: string;
  href: string;
  icon?: "ship" | "truck" | "train" | "plane" | "warehouse";
  image?: { src?: string; alt?: string }; // src "" = labelled placeholder
}
```

## Behaviour

**Card hover and keyboard focus** (from the measured states, 0.4s):

- the photo scales to **1.1** (0.6s, clipped by the 20px card radius);
- the icon tile's orange "Hover Bg" layer fades **0 → 1** and the icon turns
  white;
- the pink arrow tile's arrow slides out to the right as a second arrow
  slides in from the left;
- the **description opens** (a `0fr → 1fr` grid-row transition plus fade,
  20px gap), so the panel grows upward and lifts the icon tile with it —
  the "icon wrap shifts" the reference shows.

The same state applies on `:focus-visible`, so keyboard users see the
description too. The card gets a 3px orange focus ring.

**Pill hover** (measured): bg `#fd5523` → `#020e28`, the ↗ arrow rotates 45°.

**Appear** (measured): the header and each card start at `opacity 0,
translateY(40px)` and rise over 0.8s, cards 0.1s apart, once the section is
15% in view. Hidden start only after the script marks the root ready.

**Reduced motion:** no rise, no zoom, no slides; the description still
opens (it's content, not decoration), without a transition.

## Layout

| Width | Layout |
| :--- | :--- |
| ≥ 1200 | Header row (heading left, pill bottom-right); 3 × 388×430 cards, 30px gap, in 1224px; panel inset 20px with 20/30px padding; description folded until hover on fine pointers. |
| 810–1199 | Two columns; description always shown. |
| < 810 | Header stacked, pill under the heading; one column of 350:335 cards, panel inset 12px, description always shown — as on the reference phone. |

Cards grow taller than their aspect ratio if the panel needs the room.

## Accessibility

- Each card is a single `<a>` named by its title (`aria-labelledby`) and
  described by its description (`aria-describedby`), so a screen reader
  hears "Ocean Freight, link" followed by the description, not the whole
  card text run together.
- The description is only visually folded on desktop; it is always in the
  accessibility tree.
- Photos are decorative by default; pass `alt` to give one meaning.

## Images and credits

| File | Size | Unsplash id |
| :--- | :--- | :--- |
| `public/service-photo-cards/ocean.webp` | 776×860 | jOqJbvo1P9g |
| `public/service-photo-cards/truck.webp` | 776×860 | S4FXynMI_bU |
| `public/service-photo-cards/rail.webp` | 776×860 | ziTbznvI-9g |

Unsplash License (free commercial use, no attribution required).

## Approximations

- **The −194px "offset" is centring, not a reveal.** In the dump the body
  wrapper's `translateX(-194px)` is exactly half the 388px card — Framer's
  `left: 50%; translate(-50%)`. The reveal is the description: the desktop
  DOM has none, the phone layout shows it, and the text block's
  `overflow: hidden` + 20px gap is where it opens. The hover-open is
  inferred from that structure; its timing wasn't measured.
- The **"Hover Bg" overlay** is the icon tile's orange layer (the dump's
  only `opacity: 0` nodes), not a wash over the photo.
- The **arrow swap** is inferred from the two stacked arrow images inside an
  `overflow: hidden` tile; direction and timing are a judgement call. The
  tile's pink background is left unchanged on hover (not measured).
- Icons are original line drawings. Tablet was not captured.

===== END src/components/ServicePhotoCards.md =====

===== FILE (required): public/service-photo-cards/ocean.webp =====
[binary asset — download https://astro.baysixmedia.com/service-photo-cards/ocean.webp and save it to public/service-photo-cards/ocean.webp]

===== FILE (required): public/service-photo-cards/truck.webp =====
[binary asset — download https://astro.baysixmedia.com/service-photo-cards/truck.webp and save it to public/service-photo-cards/truck.webp]

===== FILE (required): public/service-photo-cards/rail.webp =====
[binary asset — download https://astro.baysixmedia.com/service-photo-cards/rail.webp and save it to public/service-photo-cards/rail.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
“Service photo cards” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-photo-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
4 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/ServicePhotoCards.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/service-photo-cards/ocean.webp, public/service-photo-cards/truck.webp, public/service-photo-cards/rail.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * ServicePhotoCards
 *
 * A services section: an orange eyebrow and a heading on the left with an
 * orange pill link on the right, over three tall photo cards. Each card is a
 * single link: a photo with a white panel pinned to its bottom holding an
 * icon tile that pokes out above it, the service name and a pink arrow tile.
 *
 * Desktop hover (and keyboard focus): the photo zooms to 1.1, the icon tile
 * fills orange, the arrow slides through, and a short description opens in
 * the panel, which grows upward and lifts the icon tile with it. On touch
 * screens and below 1200px the description is always shown, as the
 * reference's phone layout does.
 *
 * On scroll the header and the cards rise 40px into place, cards 0.1s apart.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type ServicePhotoIcon = "ship" | "truck" | "train" | "plane" | "warehouse";

export interface ServicePhotoCard {
	title: string;
	description?: string;
	href: string;
	icon?: ServicePhotoIcon;
	/** `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
}

export interface ServicePhotoCardsProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** The orange pill beside the heading. `null` drops it. */
	cta?: { label: string; href: string } | null;
	cards?: ServicePhotoCard[];
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ServicePhotoCardsProps {}

const {
	eyebrow = "Our services",
	heading = "",
	headingLevel = "h2",
	cta = null,
	cards = [],
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* Each card is one link: it needs a title and an href, or it is dropped rather
   than drawn half-empty. A pill needs a label and an href. No cards, no section:
   the eyebrow and heading alone never render. */
const list = cards.filter((card) => card?.title && card?.href);
const pill = cta?.label && cta?.href ? cta : null;

const uid = id ?? `spc-${Math.random().toString(36).slice(2, 8)}`;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* Measured card: 388 × 430 at desktop; the files are 776 × 860. */
const IMAGE_SPEC = "~0.9:1 · 776 × 860 px";
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class:list={["spc", className]} id={id} style={`--spc-bg: ${background};`} data-spc-root>
	<div class="spc-inner">
		<div class="spc-head" data-spc-rise>
			<div class="spc-titles">
				{
					eyebrow && (
						<p class="spc-eyebrow">
							{eyebrow}
							<span class="spc-rule" aria-hidden="true" />
						</p>
					)
				}
				{heading && <Heading class="spc-heading">{heading}</Heading>}
			</div>
			{
				pill && (
					<a class="spc-btn" href={safeHref(pill.href)}>
						{pill.label}
						<svg class="spc-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M2 10 10 2M3.5 2H10v6.5" />
						</svg>
					</a>
				)
			}
		</div>

		<ul class="spc-grid" role="list">
			{
				list.map((card, index) => {
					const titleId = `${uid}-t${index}`;
					const descId = `${uid}-d${index}`;
					const img = card.image ?? {};
					return (
						<li class="spc-item" data-spc-rise style={`--i: ${index};`}>
							<a
								class="spc-card"
								href={safeHref(card.href)}
								aria-labelledby={titleId}
								aria-describedby={card.description ? descId : undefined}
							>
								<span class="spc-media" aria-hidden={img.src && img.alt ? undefined : "true"}>
									{img.src ? (
										<img src={img.src} alt={img.alt ?? ""} width="776" height="860" loading="lazy" decoding="async" />
									) : (
										<span class="spc-ph">
											<span class="spc-ph-label">Image</span>
											<span class="spc-ph-hint">{card.title}, tall photo</span>
											<span class="spc-ph-spec">{IMAGE_SPEC}</span>
										</span>
									)}
								</span>
								<span class="spc-body">
									<span class="spc-icon" aria-hidden="true">
										<span class="spc-icon-hover" />
										<svg viewBox="0 0 40 40" width="40" height="40" focusable="false">
											{card.icon === "truck" ? (
												<g>
													<rect x="10" y="5" width="20" height="26" rx="3" />
													<path d="M10 13h20M10 22h20M14 9h12" />
													<circle cx="15" cy="26.5" r="1.5" />
													<circle cx="25" cy="26.5" r="1.5" />
													<path d="M13 31v4M27 31v4M7 14v6M33 14v6" />
												</g>
											) : card.icon === "train" ? (
												<g>
													<path d="M12 5h16a3 3 0 0 1 3 3v19a3 3 0 0 1-3 3H12a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z" />
													<path d="M9 17h22M15 9h10" />
													<rect x="13" y="11" width="14" height="6" />
													<circle cx="14" cy="24" r="1.5" />
													<circle cx="26" cy="24" r="1.5" />
													<path d="m13 30-4 6M27 30l4 6M11 33h18M4 36h32" />
												</g>
											) : card.icon === "plane" ? (
												<g>
													<path d="M4 22h12l8-12h4l-4 12h8l3-4h3l-1.5 6 1.5 6h-3l-3-4h-8l4 12h-4l-8-12H4" transform="translate(0 -4)" />
												</g>
											) : card.icon === "warehouse" ? (
												<g>
													<path d="M4 15 20 6l16 9v19H4Z" />
													<path d="M10 34V20h20v14M10 25h20M10 30h20" />
												</g>
											) : (
												<g>
													<path d="M4 27h32l-4 7H9Z" />
													<path d="M9 27v-8h22v8" />
													<path d="M13 19v-6h10v6M18 13V7h4M26 23h1M14 23h1M20 23h1" />
													<path d="M3 37c2.5 0 2.5-1.5 5-1.5s2.5 1.5 5 1.5 2.5-1.5 5-1.5 2.5 1.5 5 1.5 2.5-1.5 5-1.5 2.5 1.5 5 1.5 2.5-1.5 4-1.5" />
												</g>
											)}
										</svg>
									</span>
									<span class="spc-text">
										<span class="spc-title-row">
											<span class="spc-title" id={titleId}>{card.title}</span>
											<span class="spc-arrow" aria-hidden="true">
												<svg class="spc-arrow-a" viewBox="0 0 18 16" width="18" height="16" focusable="false">
													<path d="M1.5 8h15M10 1.5 16.5 8 10 14.5" />
												</svg>
												<svg class="spc-arrow-b" viewBox="0 0 18 16" width="18" height="16" focusable="false">
													<path d="M1.5 8h15M10 1.5 16.5 8 10 14.5" />
												</svg>
											</span>
										</span>
										{card.description && (
											<span class="spc-desc-wrap">
												<span class="spc-desc" id={descId}>{card.description}</span>
											</span>
										)}
									</span>
								</span>
							</a>
						</li>
					);
				})
			}
		</ul>
	</div>
</section>
)}

<style>
	.spc {
		--spc-navy: var(--tg-navy, #020e28);
		--spc-orange: var(--tg-orange, #fd5523);
		--spc-muted: var(--tg-muted, #61697d);
		--spc-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--spc-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--spc-ease: cubic-bezier(0.25, 0.1, 0.25, 1);

		box-sizing: border-box;
		padding: 80px 20px;
		background: var(--spc-bg, #fff);
		color: var(--spc-navy);
	}

	.spc *,
	.spc *::before,
	.spc *::after {
		box-sizing: border-box;
	}

	.spc-inner {
		display: flex;
		flex-direction: column;
		gap: 30px;
		max-width: 1224px;
		margin: 0 auto;
	}

	/* ---- header ---- */
	.spc-head {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
	}

	.spc-titles {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		max-width: 645px;
	}

	.spc .spc-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--spc-display);
		text-transform: uppercase;
		color: var(--spc-orange);
	}

	.spc-rule {
		display: none;
		width: 40px;
		height: 1px;
		background: var(--spc-orange);
	}

	.spc-heading {
		margin: 0;
		font: 600 32px/1.2 var(--spc-display);
		letter-spacing: normal;
		text-transform: capitalize;
		color: var(--spc-navy);
	}

	.spc .spc-btn {
		display: inline-flex;
		flex: none;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		padding: 18px 24px;
		border-radius: 100px;
		background: var(--spc-orange);
		font: 600 14px/12.46px var(--spc-body);
		text-transform: capitalize;
		color: #fff;
		text-decoration: none;
		transition: background-color 0.3s var(--spc-ease);
	}

	.spc-btn-arrow {
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.3s var(--spc-ease);
	}

	.spc .spc-btn:hover {
		background: var(--spc-navy);
	}

	.spc .spc-btn:hover .spc-btn-arrow {
		transform: rotate(45deg);
	}

	.spc .spc-btn:focus-visible {
		outline: 2px solid var(--spc-navy);
		outline-offset: 3px;
	}

	/* ---- cards ---- */
	.spc .spc-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.spc-card {
		position: relative;
		isolation: isolate;
		display: flex;
		flex-direction: column;
		justify-content: flex-end;
		aspect-ratio: 350 / 335;
		padding: 12px;
		border-radius: 20px;
		overflow: hidden;
		color: var(--spc-navy);
		text-decoration: none;
		background: #dfe5e7;
	}

	.spc-card:focus-visible {
		outline: 3px solid var(--spc-orange);
		outline-offset: 4px;
	}

	.spc-media {
		position: absolute;
		inset: 0;
		z-index: -1;
		display: block;
	}

	.spc-media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		transition: transform 0.6s var(--spc-ease);
	}

	.spc-ph {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: 8px;
		width: 100%;
		height: 100%;
		padding: 16px 16px 45%;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.spc-ph-label {
		font: 600 12px/1 var(--spc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.spc-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--spc-body);
	}

	.spc-ph-spec {
		font: 400 12px/1.2 var(--spc-body);
	}

	.spc-body {
		position: relative;
		display: block;
		margin-top: 60px;
		padding: 40px 12px 16px;
		border-radius: 10px;
		background: #fff;
	}

	.spc-icon {
		position: absolute;
		top: -39px;
		left: 12px;
		display: grid;
		place-items: center;
		width: 60px;
		height: 60px;
		border-radius: 5px;
		background: #fff;
		box-shadow:
			rgb(12 12 13 / 0.05) 0 1px 4px 0,
			rgb(12 12 13 / 0.1) 0 1px 4px 0;
		color: var(--spc-navy);
		transition: color 0.4s var(--spc-ease);
	}

	.spc-icon-hover {
		position: absolute;
		inset: 0;
		border-radius: 5px;
		background: var(--spc-orange);
		opacity: 0;
		transition: opacity 0.4s var(--spc-ease);
	}

	.spc-icon svg {
		position: relative;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.5;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.spc-text {
		display: block;
	}

	.spc-title-row {
		display: flex;
		justify-content: space-between;
		align-items: flex-end;
		gap: 16px;
	}

	.spc-title {
		max-width: 180px;
		font: 700 20px/24px var(--spc-display);
		color: var(--spc-navy);
	}

	.spc-arrow {
		position: relative;
		flex: none;
		display: block;
		width: 40px;
		height: 40px;
		border-radius: 10px;
		background: #ffe0e0;
		overflow: hidden;
	}

	.spc-arrow svg {
		position: absolute;
		top: 12px;
		left: 11px;
		fill: none;
		stroke: var(--spc-orange);
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.4s var(--spc-ease);
	}

	.spc-arrow-b {
		transform: translateX(-30px);
	}

	.spc-desc-wrap {
		display: grid;
		grid-template-rows: 1fr;
		margin-top: 12px;
		transition:
			grid-template-rows 0.4s var(--spc-ease),
			margin-top 0.4s var(--spc-ease),
			opacity 0.4s var(--spc-ease);
	}

	.spc-desc {
		display: block;
		min-height: 0;
		overflow: hidden;
		font: 400 15px/20px var(--spc-body);
		color: var(--spc-muted);
	}

	/* ---- hover / focus ---- */
	.spc-card:hover .spc-media img,
	.spc-card:focus-visible .spc-media img {
		transform: scale(1.1);
	}

	.spc-card:hover .spc-icon,
	.spc-card:focus-visible .spc-icon {
		color: #fff;
	}

	.spc-card:hover .spc-icon-hover,
	.spc-card:focus-visible .spc-icon-hover {
		opacity: 1;
	}

	.spc-card:hover .spc-arrow-a,
	.spc-card:focus-visible .spc-arrow-a {
		transform: translateX(30px);
	}

	.spc-card:hover .spc-arrow-b,
	.spc-card:focus-visible .spc-arrow-b {
		transform: translateX(0);
	}

	/* ---- appear ---- */
	[data-spc-rise] {
		transition:
			opacity 0.8s var(--spc-ease) calc(var(--i, 0) * 0.1s),
			transform 0.8s var(--spc-ease) calc(var(--i, 0) * 0.1s);
	}

	.spc[data-spc-ready]:not([data-in]) [data-spc-rise] {
		opacity: 0;
		transform: translateY(40px);
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.spc {
			padding: 100px 30px;
		}

		.spc-inner {
			gap: 50px;
		}

		.spc-head {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-end;
		}

		.spc .spc-eyebrow {
			font-size: 18px;
			line-height: 12.6px;
		}

		.spc-rule {
			display: block;
		}

		.spc-heading {
			font-size: 40px;
		}

		.spc .spc-btn {
			min-height: 56px;
			padding: 22px 30px;
		}

		.spc .spc-grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 30px;
		}

		.spc-card {
			aspect-ratio: 388 / 430;
			padding: 20px;
		}

		.spc-body {
			padding: 40px 30px 20px;
		}

		.spc-icon {
			left: 30px;
		}

		.spc-title {
			max-width: 150px;
			font-size: 22px;
			line-height: 26px;
		}
	}

	/* ---- desktop: three 388 × 430 cards ---- */
	@media (min-width: 1200px) {
		.spc {
			padding: 120px 20px;
		}

		.spc-inner {
			gap: 60px;
		}

		.spc-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.spc .spc-grid {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.spc-title {
			max-width: 140px;
		}
	}

	/* Desktop with a real pointer: the description stays folded until the
	   card is hovered or focused, and the panel grows upward to show it. */
	@media (min-width: 1200px) and (hover: hover) and (pointer: fine) {
		.spc-desc-wrap {
			grid-template-rows: 0fr;
			margin-top: 0;
			opacity: 0;
		}

		.spc-card:hover .spc-desc-wrap,
		.spc-card:focus-visible .spc-desc-wrap {
			grid-template-rows: 1fr;
			margin-top: 20px;
			opacity: 1;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		[data-spc-rise],
		.spc-media img,
		.spc-arrow svg,
		.spc-desc-wrap,
		.spc-btn-arrow {
			transition: none;
		}

		.spc-card:hover .spc-media img,
		.spc-card:focus-visible .spc-media img,
		.spc .spc-btn:hover .spc-btn-arrow {
			transform: none;
		}

		.spc[data-spc-ready]:not([data-in]) [data-spc-rise] {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.spc-body,
		.spc .spc-btn {
			border: 1px solid CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll. One IntersectionObserver per instance, scoped to its
	 * root, torn down on astro:before-swap; sets `data-in` once. The hidden
	 * start pose applies only after the root is marked ready, so no-JS and
	 * reduced motion show everything at rest. Hovers are pure CSS.
	 */
	function initServicePhotoCards(root: HTMLElement) {
		if (root.dataset.spcReady === "") return;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.spcReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.spcReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-spc-root]").forEach(initServicePhotoCards);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>