Component library
Back to catalog

Stacked service cards

A navy services band: a sticky image card with a huge display heading on the left, and numbered service cards on the right that pile on top of each other as you scroll, the covered ones shrinking slightly. Pure CSS; a plain column on phones.

servicescardsstickystackscroll-drivenstatic

Open preview in a new tab

Use this component

Three ways to get Stacked service 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 1 file 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 Stacked service cards from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/stacked-service-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 1 file 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: "Stacked service cards"
Library page: https://astro.baysixmedia.com/components/stacked-service-cards/
Files: 1 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/StackedServiceCards.astro =====
---
/**
 * Stacked service cards
 *
 * A navy services band. On the left, a sticky image card with a huge display
 * heading and a pill link. On the right, one card per service, each sticky at
 * the same offset so they pile on top of one another as the page scrolls; the
 * cards already covered shrink slightly as the next one slides over them.
 *
 * No script. The pile is `position: sticky`. The shrink is a CSS
 * scroll-driven animation: each card's scale runs on a named view timeline
 * belonging to the card after it, so it tracks that card's approach. Browsers
 * without scroll-driven animations keep the pile and skip the shrink; reduced
 * motion skips it too. Below 1024px the section becomes a plain column with no
 * sticking, which is what the phone reference does.
 *
 * The sticky card's picture is a prop. Unset, it renders a labelled striped
 * placeholder saying what photo belongs there and at what size.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Colours and
 * fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
 */

export interface ImageSlot {
	/** Image URL. Unset renders the labelled placeholder. */
	src?: string;
	/** Alt text. Decorative ("") by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder. */
	hint?: string;
}

export interface ServiceCard {
	/** Card heading. */
	title: string;
	/** One or two sentences under it. */
	description: string;
	/** The check list. Two or three short lines reads best. */
	features?: string[];
	/** Where "View details" goes. Omit to drop the link. */
	href?: string;
	/** Card surface. Defaults alternate light, accent, light, accent. */
	tone?: "light" | "accent";
}

interface Props {
	/** Root id. */
	id?: string;
	/** The display heading in the sticky card. */
	heading?: string;
	/** Heading level for the section heading; cards use the next level down. */
	headingLevel?: "h2" | "h3";
	/**
	 * Background of the sticky card, under a 60% black overlay. `null` gives a
	 * plain navy-tint card with no picture and no placeholder.
	 */
	image?: ImageSlot | null;
	/** Pill link under the heading. `""` label drops it. */
	ctaLabel?: string;
	ctaHref?: string;
	/** The small uppercase label above each check list. */
	featuresLabel?: string;
	/** Text of each card's link. The card title is appended for screen readers. */
	detailLabel?: string;
	/** The cards, in order. */
	services?: ServiceCard[];
	/** Pile the cards with sticky positioning (desktop only). */
	stack?: boolean;
	/** Shrink covered cards while scrolling. Needs `stack`. */
	shrink?: boolean;
	/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
	fontStylesheet?: string;
}

const {
	id,
	heading = "Our services",
	headingLevel = "h2",
	image = {},
	ctaLabel = "View all services",
	ctaHref = "",
	featuresLabel = "What's included",
	detailLabel = "View details",
	services = [],
	stack = true,
	shrink = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;

const Heading = headingLevel;

/* The slot's shape at desktop: 588 x 702 in the reference. */
const IMAGE_SPEC = "~5:6 · 1200 × 1440 px";
/* No hint by default: the placeholder says "Image" and the size, nothing about
   a particular business. Pass `image.hint` to describe the photo wanted. */
const imageHint = image?.hint ?? "";
const CardHeading = headingLevel === "h2" ? "h3" : "h4";

/* A card needs a title and a description; one missing either is dropped rather
   than drawn half-empty. No cards, no section: the heading alone never shows. */
const list = services
	.filter((service) => service?.title && service?.description)
	.map((service) => ({ ...service, features: service.features?.filter(Boolean) }));

/*
 * Each card owns a named view timeline; the card before it animates on that
 * timeline. The names only have to be unique inside one list, because
 * `timeline-scope` on the list stops the lookup there — two instances on a
 * page never see each other's timelines.
 */
const names = list.map((_, i) => `--ssc-card-${i + 1}`);
const listStyle = names.length > 1 ? `timeline-scope: ${names.join(", ")};` : undefined;
const pad = (n: number) => String(n).padStart(2, "0");
---

{list.length > 0 && (
<section
	class="ssc"
	id={id}
	data-ssc-root
	data-stack={stack ? "" : undefined}
	data-shrink={stack && shrink ? "" : undefined}
>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="ssc__inner">
		<div class:list={["ssc__intro", { "ssc__intro--photo": Boolean(image?.src) }]}>
			{image?.src && <img class="ssc__intro-img" src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />}
			{
				image && !image.src && (
					<div class="ssc-ph" role="img" aria-label={imageHint ? `Image placeholder: ${imageHint}` : "Image placeholder"}>
						<span class="ssc-ph__text">
							<span class="ssc-ph__label">Image</span>
							{imageHint && <span class="ssc-ph__hint">{imageHint}</span>}
							<span class="ssc-ph__spec">{IMAGE_SPEC}</span>
						</span>
					</div>
				)
			}
			<div class="ssc__intro-text">
				<Heading class="ssc__heading">{heading}</Heading>
				{
					ctaLabel && ctaHref && (
						<a class="ssc__pill" href={ctaHref}>
							<span>{ctaLabel}</span>
							<span class="ssc__pill-icon" aria-hidden="true">
								<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
							</span>
						</a>
					)
				}
			</div>
		</div>

		<ol class="ssc__list" style={listStyle}>
			{
				list.map((service, i) => {
					const tone = service.tone ?? (i % 2 === 0 ? "light" : "accent");
					const next = names[i + 1];
					return (
						<li
							class:list={["ssc__item", { "ssc__item--covered": Boolean(next) }]}
							style={`--ssc-self: ${names[i]};${next ? ` --ssc-next: ${next};` : ""}`}
						>
							<article class:list={["ssc__card", `ssc__card--${tone}`]}>
								<div class="ssc__body">
									<p class="ssc__num" aria-hidden="true">{pad(i + 1)}</p>
									<div class="ssc__content">
										<div class="ssc__text">
											<CardHeading class="ssc__title">{service.title}</CardHeading>
											<p class="ssc__desc">{service.description}</p>
										</div>
										{service.features && service.features.length > 0 && (
											<div class="ssc__features">
												{featuresLabel && <p class="ssc__label">{featuresLabel}</p>}
												<ul class="ssc__checks">
													{service.features.map((feature) => (
														<li>
															<svg class="ssc__check" viewBox="0 0 20 20" width="19" height="19" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
																<rect x="2.5" y="2.5" width="15" height="15" rx="3" />
																<path d="M6.5 10.2l2.4 2.4 4.8-5" />
															</svg>
															<span>{feature}</span>
														</li>
													))}
												</ul>
											</div>
										)}
									</div>
								</div>
								{service.href && (
									<a class="ssc__link" href={service.href}>
										<span>
											{detailLabel}
											<span class="ssc__sr">: {service.title}</span>
										</span>
										<span class="ssc__link-icon" aria-hidden="true">
											<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
										</span>
									</a>
								)}
							</article>
						</li>
					);
				})
			}
		</ol>
	</div>
</section>
)}

<style>
	.ssc {
		--ssc-navy: var(--lx-navy, #0a2540);
		--ssc-accent: var(--lx-accent, #ff5f00);
		--ssc-ink-soft: var(--lx-ink-soft, #555555);
		--ssc-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--ssc-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--ssc-top: 50px;

		box-sizing: border-box;
		background: var(--ssc-navy);
		padding: 60px 20px;
		font-family: var(--ssc-body);
		overflow-x: clip;
	}

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

	.ssc__inner {
		max-width: 1200px;
		margin: 0 auto;
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	/* ---- sticky intro card ---- */

	.ssc__intro {
		position: relative;
		isolation: isolate;
		overflow: hidden;
		border-radius: 10px;
		padding: 24px;
		background: #133a5e;
	}

	.ssc__intro-img {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
		z-index: -2;
	}

	/*
	 * The measured overlay: black at 60%, over a real photo only. The
	 * placeholder is already a dark stripe, and dimming it further would make
	 * its label unreadable.
	 */
	.ssc__intro--photo::before {
		content: "";
		position: absolute;
		inset: 0;
		background: rgba(0, 0, 0, 0.6);
		z-index: -1;
	}

	/* ---- image placeholder (dark context) ---- */

	.ssc-ph {
		position: absolute;
		inset: 0;
		z-index: -2;
		display: grid;
		/* The heading and pill sit at the top; the label sits in the empty
		   lower part of the card so the two never overlap. */
		align-items: end;
		justify-items: center;
		padding: 16px;
		border: 1px dashed rgba(255, 255, 255, 0.25);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px);
		color: rgba(255, 255, 255, 0.72);
		text-align: center;
		font-family: var(--ssc-body);
	}

	/* On phones and tablets the card is a short strip filled by the heading,
	   so the placeholder keeps its stripes and drops its words. */
	.ssc-ph__text {
		display: none;
		flex-direction: column;
		align-items: center;
		gap: 8px;
		max-width: 28ch;
		padding-bottom: 64px;
	}

	.ssc-ph__label {
		font: 500 11px/1 var(--ssc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.ssc-ph__hint {
		font: 500 14px/1.4 var(--ssc-body);
	}

	.ssc-ph__spec {
		font: 400 12px/1.2 var(--ssc-body);
	}

	.ssc__intro-text {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
	}

	.ssc__heading {
		margin: 0;
		color: #fff;
		font-family: var(--ssc-display);
		font-weight: 400;
		font-size: 40px;
		line-height: 0.8;
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	/* ---- pills ---- */

	.ssc__pill,
	.ssc__link {
		display: inline-flex;
		align-items: center;
		gap: 16px;
		min-height: 50px;
		padding: 5px 5px 5px 19px;
		border-radius: 100px;
		font: 600 14px/1 var(--ssc-body);
		text-decoration: none;
		transition: background-color 0.2s ease, border-color 0.2s ease;
	}

	.ssc__pill {
		background: var(--ssc-accent);
		color: #fff;
	}

	.ssc__pill-icon,
	.ssc__link-icon {
		display: grid;
		place-items: center;
		flex: none;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		transition: transform 0.25s ease;
	}

	.ssc__pill-icon {
		background: #fff;
		color: var(--ssc-accent);
	}

	.ssc__pill:hover .ssc__pill-icon,
	.ssc__link:hover .ssc__link-icon {
		transform: translateX(3px);
	}

	.ssc__pill:focus-visible,
	.ssc__link:focus-visible {
		outline: 2px solid #fff;
		outline-offset: 3px;
	}

	.ssc__card--light .ssc__link:focus-visible {
		outline-color: var(--ssc-navy);
	}

	/* ---- cards ---- */

	.ssc__list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	.ssc__card {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		padding: 24px;
		border-radius: 10px;
		transform-origin: 50% 50%;
	}

	.ssc__card--light {
		background: #fff;
		--ssc-c-num: rgba(10, 37, 64, 0.3);
		--ssc-c-title: var(--ssc-navy);
		--ssc-c-text: var(--ssc-ink-soft);
		--ssc-c-strong: var(--ssc-navy);
	}

	.ssc__card--accent {
		background: var(--ssc-accent);
		--ssc-c-num: rgba(255, 255, 255, 0.6);
		--ssc-c-title: #fff;
		--ssc-c-text: rgba(255, 255, 255, 0.85);
		--ssc-c-strong: #fff;
	}

	.ssc__body {
		display: flex;
		flex-direction: column;
		gap: 16px;
		width: 100%;
	}

	.ssc__num {
		margin: 0;
		color: var(--ssc-c-num);
		font: 400 48px/0.8 var(--ssc-display);
		letter-spacing: -0.02em;
	}

	.ssc__content {
		display: flex;
		flex-direction: column;
		gap: 24px;
	}

	.ssc__text {
		display: flex;
		flex-direction: column;
		gap: 10px;
	}

	.ssc__title {
		margin: 0;
		color: var(--ssc-c-title);
		font: 400 32px/1 var(--ssc-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.ssc__desc,
	.ssc__checks {
		margin: 0;
		color: var(--ssc-c-text);
		font: 400 16px/1.6 var(--ssc-body);
	}

	.ssc__features {
		display: flex;
		flex-direction: column;
		gap: 20px;
	}

	.ssc__label {
		margin: 0;
		color: var(--ssc-c-strong);
		font: 500 14px/1 var(--ssc-body);
		text-transform: uppercase;
	}

	.ssc__checks {
		list-style: none;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 12px;
	}

	.ssc__checks li {
		display: flex;
		align-items: flex-start;
		gap: 6px;
	}

	.ssc__check {
		flex: none;
		margin-top: 3px;
		color: var(--ssc-c-strong);
	}

	.ssc__link {
		border: 1px solid;
	}

	.ssc__card--light .ssc__link {
		background: #fff;
		border-color: rgba(10, 37, 64, 0.14);
		color: var(--ssc-navy);
	}

	.ssc__card--light .ssc__link-icon {
		background: var(--ssc-navy);
		color: #fff;
	}

	.ssc__card--accent .ssc__link {
		background: transparent;
		border-color: rgba(255, 255, 255, 0.45);
		color: #fff;
	}

	.ssc__card--accent .ssc__link-icon {
		background: #fff;
		color: var(--ssc-accent);
	}

	.ssc__card--light .ssc__link:hover {
		border-color: var(--ssc-navy);
	}

	.ssc__card--accent .ssc__link:hover {
		background: rgba(255, 255, 255, 0.12);
	}

	.ssc__sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- tablet: same column, more room ---- */

	@media (min-width: 810px) {
		.ssc {
			padding: 100px 30px;
		}

		.ssc__intro {
			padding: 48px;
		}

		.ssc__heading {
			font-size: 72px;
		}

		.ssc__card {
			padding: 48px;
			gap: 40px;
		}

		.ssc__num {
			font-size: 96px;
		}

		.ssc__title {
			font-size: 40px;
		}
	}

	/* ---- desktop: two columns, sticky pile ---- */

	@media (min-width: 1024px) {
		.ssc {
			padding: 120px 30px;
		}

		.ssc__inner {
			display: grid;
			grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
			align-items: start;
			gap: 24px;
		}

		.ssc__intro {
			padding: clamp(48px, 5.5vw, 80px) clamp(48px, 5.5vw, 80px) 320px;
		}

		.ssc__intro-text {
			gap: 60px;
		}

		.ssc-ph__text {
			display: flex;
		}

		.ssc__heading {
			font-size: clamp(64px, 8.4vw, 120px);
		}

		.ssc__list {
			gap: 40px;
		}

		.ssc__card {
			padding: clamp(48px, 5.5vw, 80px);
			gap: 60px;
		}

		.ssc__body {
			gap: 24px;
		}

		.ssc__content {
			gap: 40px;
		}

		.ssc__features {
			gap: 24px;
		}

		.ssc__num {
			font-size: 120px;
		}

		.ssc__title {
			font-size: 48px;
		}

		[data-stack] .ssc__intro,
		[data-stack] .ssc__item {
			position: sticky;
			top: var(--ssc-top);
		}
	}

	/*
	 * The shrink. Card N scales on the view timeline of card N+1: it starts as
	 * N+1's top edge enters the viewport and finishes 400px later, well before
	 * N+1 reaches its sticky offset, so a covered card is always at rest. Where
	 * the browser lacks scroll-driven animations or `timeline-scope`, the cards
	 * still pile — they just don't shrink. Reduced motion never shrinks.
	 */
	@supports (animation-timeline: view()) and (timeline-scope: --ssc-a) {
		@media (min-width: 1024px) and (prefers-reduced-motion: no-preference) {
			[data-stack] .ssc__item {
				view-timeline-name: var(--ssc-self);
			}

			[data-shrink] .ssc__item--covered .ssc__card {
				animation: ssc-shrink linear both;
				animation-timeline: var(--ssc-next);
				animation-range: entry 0% entry 400px;
			}
		}
	}

	@keyframes ssc-shrink {
		from {
			transform: scale(1);
		}
		to {
			transform: scale(0.966);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.ssc__pill,
		.ssc__link,
		.ssc__pill-icon,
		.ssc__link-icon {
			transition: none;
		}

		.ssc__pill:hover .ssc__pill-icon,
		.ssc__link:hover .ssc__link-icon {
			transform: none;
		}
	}
</style>

===== END src/components/StackedServiceCards.astro =====

===== FILE (supporting): src/components/StackedServiceCards.md =====
# Stacked service cards

A navy services band. On the left, a sticky image card with a huge display
heading and a pill link. On the right, numbered service cards that pile on
top of one another as the page scrolls, with each covered card shrinking
slightly as the next slides over it.

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

## Purpose

The services block on a homepage for a business with three to five
headline services, each with a sentence and a couple of bullet points. The
pile makes a long list feel short: the visitor sees one card at a time and
the section's heading stays in view.

Use something else for more than about five services (the section gets
very tall) or for services that need images of their own.

## Files required

**`StackedServiceCards.astro`**: the whole component.

- No imports, no packages, no script.
- No global stylesheet. Colours and fonts read the page's `--lx-*` tokens,
  with fallbacks, so the component works on its own.
- Loads Bebas Neue and Poppins from Google Fonts unless `fontStylesheet=""`.
- **No image files.** By default, the sticky card shows a labelled
  placeholder (see Images below) until you pass `image.src`.

`StackedServiceCards.md`: this file. Documentation only.

## Basic usage

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

<StackedServiceCards
  heading="What we do"
  image={{ src: "/img/fleet.jpg", alt: "" }}
  ctaHref="/services"
  services={[
    {
      title: "Truckload freight",
      description: "Dedicated trailers on regional and national lanes.",
      features: ["FTL capacity", "LTL consolidation"],
      href: "/services/truckload",
    },
    // ...
  ]}
/>
```

## Props

| Prop | Type | Default | Notes |
| :--- | :--- | :--- | :--- |
| `id` | `string` | none | Applied to the `<section>`. |
| `heading` | `string` | `"Our services"` | The display heading in the sticky card. |
| `headingLevel` | `"h2" \| "h3"` | `"h2"` | Card titles use the next level down. |
| `image` | `{ src?, alt?, hint? } \| null` | `{}` (placeholder) | Sticky card background. No `src` renders the placeholder. `null` gives a plain navy-tint card. `alt` defaults to `""` (decorative). |
| `ctaLabel` / `ctaHref` | `string` | `"View all services"` / `""` | The orange pill. It renders only when both are set, so pass `ctaHref` to show it. |
| `featuresLabel` | `string` | `"What's included"` | Small label above each check list. `""` drops it. |
| `detailLabel` | `string` | `"View details"` | Each card's link text. The card title is appended for screen readers. |
| `services` | `ServiceCard[]` | `[]` | `{ title, description, features?, href?, tone? }`. A card missing its `title` or `description` is dropped. With no cards the component renders nothing — the heading and image card alone never show. The catalog demo passes its four freight services as props. |
| `stack` | `boolean` | `true` | Sticky pile (1024px and up). |
| `shrink` | `boolean` | `true` | Scroll-linked scale on covered cards. Needs `stack`. |
| `fontStylesheet` | `string` | Google Fonts URL | `""` if the page already loads the fonts. |

`tone` is `"light"` (white card, navy type) or `"accent"` (orange card,
white type). Cards alternate light and accent unless you set it.

## Images

One photo slot. With no `src` it renders a striped placeholder that shows
`IMAGE`, the hint (if you pass one) and the spec line below, with `role="img"`
and an `aria-label` of "Image placeholder: {hint}", or just "Image
placeholder" with no hint. There is no default hint.

| Slot | Default hint | Ratio · size |
| :--- | :--- | :--- |
| `image` (sticky card) | none (the catalog demo passes "Box truck seen from behind on an empty highway at dusk, tail lights on, cool blue haze") | ~5:6 · 1200 × 1440 px |

- The slot is 588 × 702 at desktop. On phones it crops to a wide strip
  about 335 × 154, so keep the subject centred.
- A real photo gets the measured 60% black overlay so the white heading
  reads over it.
- The placeholder does **not** get the overlay. It is already a dark stripe
  on navy, and dimming it further would make its label unreadable.
- The placeholder's words sit in the empty lower part of the card, so they
  never collide with the heading.
- Below 1024px the card is too short for the words, so the placeholder
  shows its stripes only. The `aria-label` still carries the hint.

## Behaviour

- **The pile** is plain `position: sticky; top: 50px` on each list item and
  on the intro card. Later items paint above earlier ones in DOM order, so
  every card slides over the last.
- **The shrink** is a CSS scroll-driven animation. Each card declares a
  named view timeline (`--ssc-card-N`), and the card before it runs
  `scale(1 → 0.966)` on that timeline, starting as the next card's top edge
  enters the viewport and finishing 400px later, before the next card
  sticks. `timeline-scope` on the list makes the names visible between
  sibling cards and stops them at the list, so two instances on one page
  cannot interfere. The last card never shrinks, as in the reference.
- **Without support** (Firefox at the time of writing) the cards still
  pile. They do not shrink. There is deliberately no JavaScript fallback,
  because a scroll handler costs more than a 3% scale is worth.
- **Below 1024px** the section becomes one column: intro card, then the
  cards in normal flow, with nothing sticky and nothing scaling. The phone
  reference drops the pile, so this does too.

## Accessibility

- A `<section>` with a real heading. The cards are `<article>`s in an
  ordered list. The big "01…04" numbers are `aria-hidden` because the list
  already numbers them.
- Each "View details" link has visually hidden text after it (": Truckload
  freight"), so a links list does not read four identical names.
- Visible `:focus-visible` outlines on both kinds of pill. Pills are 50px
  tall.
- `prefers-reduced-motion: reduce` turns off the shrink and the arrow nudge
  on hover. The pile stays, because sticking is layout and not motion.

## Integration notes

- Sticky breaks if any ancestor has `overflow: hidden | auto | scroll`.
  `overflow: clip` is fine, and it is what the section uses itself.
- The section paints its own navy background edge to edge and supplies its
  own padding. Do not wrap it in a container.

## Approximations

- **Phone typography** (number 48px, title 32px, card padding 24px) was
  read off the 390 screenshot. The phone dump covers only the last card's
  outer box.
- **Tablet (810–1023px)** is interpolated. There is no tablet capture.
  It keeps the phone's single column with 48px padding and larger type.
- **The 1024px desktop switch** is this component's choice. The reference
  appears to switch at about 1200px. Between 1024 and 1199px the two
  columns shrink fluidly and the heading scales with `8.4vw`.
- **The shrink's timing** (starts as the next card enters, done 400px
  later) is judged. The dump shows only the settled end state,
  `scale(0.966)` on every covered card.
- **The pill arrow nudge on hover** is assumed. The reference's hover
  state was not captured.
- **Placeholder spec**: 1200 × 1440 scales the 588 × 702 slot to about 2×.
  The ratio is 0.84, so "~5:6" is a rounded label.
- **"View details" border colours** (14% navy on light cards, 45% white on
  accent cards) are approximated from the screenshot. The dump shows no
  border because Framer draws it on a separate layer.

===== END src/components/StackedServiceCards.md =====

--- 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: not applicable — this component ships no script.

--- 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
“Stacked service cards” from my Astro Component Library:
https://astro.baysixmedia.com/components/stacked-service-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
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
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 fixture

src/components/library/StackedServiceCards.astro

one fileNo imports, no companion files, no packages. Copy StackedServiceCards.astro into any Astro project's components folder and render it.

---
/**
 * Stacked service cards
 *
 * A navy services band. On the left, a sticky image card with a huge display
 * heading and a pill link. On the right, one card per service, each sticky at
 * the same offset so they pile on top of one another as the page scrolls; the
 * cards already covered shrink slightly as the next one slides over them.
 *
 * No script. The pile is `position: sticky`. The shrink is a CSS
 * scroll-driven animation: each card's scale runs on a named view timeline
 * belonging to the card after it, so it tracks that card's approach. Browsers
 * without scroll-driven animations keep the pile and skip the shrink; reduced
 * motion skips it too. Below 1024px the section becomes a plain column with no
 * sticking, which is what the phone reference does.
 *
 * The sticky card's picture is a prop. Unset, it renders a labelled striped
 * placeholder saying what photo belongs there and at what size.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Colours and
 * fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
 */

export interface ImageSlot {
	/** Image URL. Unset renders the labelled placeholder. */
	src?: string;
	/** Alt text. Decorative ("") by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder. */
	hint?: string;
}

export interface ServiceCard {
	/** Card heading. */
	title: string;
	/** One or two sentences under it. */
	description: string;
	/** The check list. Two or three short lines reads best. */
	features?: string[];
	/** Where "View details" goes. Omit to drop the link. */
	href?: string;
	/** Card surface. Defaults alternate light, accent, light, accent. */
	tone?: "light" | "accent";
}

interface Props {
	/** Root id. */
	id?: string;
	/** The display heading in the sticky card. */
	heading?: string;
	/** Heading level for the section heading; cards use the next level down. */
	headingLevel?: "h2" | "h3";
	/**
	 * Background of the sticky card, under a 60% black overlay. `null` gives a
	 * plain navy-tint card with no picture and no placeholder.
	 */
	image?: ImageSlot | null;
	/** Pill link under the heading. `""` label drops it. */
	ctaLabel?: string;
	ctaHref?: string;
	/** The small uppercase label above each check list. */
	featuresLabel?: string;
	/** Text of each card's link. The card title is appended for screen readers. */
	detailLabel?: string;
	/** The cards, in order. */
	services?: ServiceCard[];
	/** Pile the cards with sticky positioning (desktop only). */
	stack?: boolean;
	/** Shrink covered cards while scrolling. Needs `stack`. */
	shrink?: boolean;
	/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
	fontStylesheet?: string;
}

const {
	id,
	heading = "Our services",
	headingLevel = "h2",
	image = {},
	ctaLabel = "View all services",
	ctaHref = "",
	featuresLabel = "What's included",
	detailLabel = "View details",
	services = [],
	stack = true,
	shrink = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;

const Heading = headingLevel;

/* The slot's shape at desktop: 588 x 702 in the reference. */
const IMAGE_SPEC = "~5:6 · 1200 × 1440 px";
/* No hint by default: the placeholder says "Image" and the size, nothing about
   a particular business. Pass `image.hint` to describe the photo wanted. */
const imageHint = image?.hint ?? "";
const CardHeading = headingLevel === "h2" ? "h3" : "h4";

/* A card needs a title and a description; one missing either is dropped rather
   than drawn half-empty. No cards, no section: the heading alone never shows. */
const list = services
	.filter((service) => service?.title && service?.description)
	.map((service) => ({ ...service, features: service.features?.filter(Boolean) }));

/*
 * Each card owns a named view timeline; the card before it animates on that
 * timeline. The names only have to be unique inside one list, because
 * `timeline-scope` on the list stops the lookup there — two instances on a
 * page never see each other's timelines.
 */
const names = list.map((_, i) => `--ssc-card-${i + 1}`);
const listStyle = names.length > 1 ? `timeline-scope: ${names.join(", ")};` : undefined;
const pad = (n: number) => String(n).padStart(2, "0");
---

{list.length > 0 && (
<section
	class="ssc"
	id={id}
	data-ssc-root
	data-stack={stack ? "" : undefined}
	data-shrink={stack && shrink ? "" : undefined}
>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="ssc__inner">
		<div class:list={["ssc__intro", { "ssc__intro--photo": Boolean(image?.src) }]}>
			{image?.src && <img class="ssc__intro-img" src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />}
			{
				image && !image.src && (
					<div class="ssc-ph" role="img" aria-label={imageHint ? `Image placeholder: ${imageHint}` : "Image placeholder"}>
						<span class="ssc-ph__text">
							<span class="ssc-ph__label">Image</span>
							{imageHint && <span class="ssc-ph__hint">{imageHint}</span>}
							<span class="ssc-ph__spec">{IMAGE_SPEC}</span>
						</span>
					</div>
				)
			}
			<div class="ssc__intro-text">
				<Heading class="ssc__heading">{heading}</Heading>
				{
					ctaLabel && ctaHref && (
						<a class="ssc__pill" href={ctaHref}>
							<span>{ctaLabel}</span>
							<span class="ssc__pill-icon" aria-hidden="true">
								<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
							</span>
						</a>
					)
				}
			</div>
		</div>

		<ol class="ssc__list" style={listStyle}>
			{
				list.map((service, i) => {
					const tone = service.tone ?? (i % 2 === 0 ? "light" : "accent");
					const next = names[i + 1];
					return (
						<li
							class:list={["ssc__item", { "ssc__item--covered": Boolean(next) }]}
							style={`--ssc-self: ${names[i]};${next ? ` --ssc-next: ${next};` : ""}`}
						>
							<article class:list={["ssc__card", `ssc__card--${tone}`]}>
								<div class="ssc__body">
									<p class="ssc__num" aria-hidden="true">{pad(i + 1)}</p>
									<div class="ssc__content">
										<div class="ssc__text">
											<CardHeading class="ssc__title">{service.title}</CardHeading>
											<p class="ssc__desc">{service.description}</p>
										</div>
										{service.features && service.features.length > 0 && (
											<div class="ssc__features">
												{featuresLabel && <p class="ssc__label">{featuresLabel}</p>}
												<ul class="ssc__checks">
													{service.features.map((feature) => (
														<li>
															<svg class="ssc__check" viewBox="0 0 20 20" width="19" height="19" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
																<rect x="2.5" y="2.5" width="15" height="15" rx="3" />
																<path d="M6.5 10.2l2.4 2.4 4.8-5" />
															</svg>
															<span>{feature}</span>
														</li>
													))}
												</ul>
											</div>
										)}
									</div>
								</div>
								{service.href && (
									<a class="ssc__link" href={service.href}>
										<span>
											{detailLabel}
											<span class="ssc__sr">: {service.title}</span>
										</span>
										<span class="ssc__link-icon" aria-hidden="true">
											<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
										</span>
									</a>
								)}
							</article>
						</li>
					);
				})
			}
		</ol>
	</div>
</section>
)}

<style>
	.ssc {
		--ssc-navy: var(--lx-navy, #0a2540);
		--ssc-accent: var(--lx-accent, #ff5f00);
		--ssc-ink-soft: var(--lx-ink-soft, #555555);
		--ssc-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--ssc-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--ssc-top: 50px;

		box-sizing: border-box;
		background: var(--ssc-navy);
		padding: 60px 20px;
		font-family: var(--ssc-body);
		overflow-x: clip;
	}

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

	.ssc__inner {
		max-width: 1200px;
		margin: 0 auto;
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	/* ---- sticky intro card ---- */

	.ssc__intro {
		position: relative;
		isolation: isolate;
		overflow: hidden;
		border-radius: 10px;
		padding: 24px;
		background: #133a5e;
	}

	.ssc__intro-img {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
		z-index: -2;
	}

	/*
	 * The measured overlay: black at 60%, over a real photo only. The
	 * placeholder is already a dark stripe, and dimming it further would make
	 * its label unreadable.
	 */
	.ssc__intro--photo::before {
		content: "";
		position: absolute;
		inset: 0;
		background: rgba(0, 0, 0, 0.6);
		z-index: -1;
	}

	/* ---- image placeholder (dark context) ---- */

	.ssc-ph {
		position: absolute;
		inset: 0;
		z-index: -2;
		display: grid;
		/* The heading and pill sit at the top; the label sits in the empty
		   lower part of the card so the two never overlap. */
		align-items: end;
		justify-items: center;
		padding: 16px;
		border: 1px dashed rgba(255, 255, 255, 0.25);
		border-radius: inherit;
		background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px);
		color: rgba(255, 255, 255, 0.72);
		text-align: center;
		font-family: var(--ssc-body);
	}

	/* On phones and tablets the card is a short strip filled by the heading,
	   so the placeholder keeps its stripes and drops its words. */
	.ssc-ph__text {
		display: none;
		flex-direction: column;
		align-items: center;
		gap: 8px;
		max-width: 28ch;
		padding-bottom: 64px;
	}

	.ssc-ph__label {
		font: 500 11px/1 var(--ssc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.ssc-ph__hint {
		font: 500 14px/1.4 var(--ssc-body);
	}

	.ssc-ph__spec {
		font: 400 12px/1.2 var(--ssc-body);
	}

	.ssc__intro-text {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
	}

	.ssc__heading {
		margin: 0;
		color: #fff;
		font-family: var(--ssc-display);
		font-weight: 400;
		font-size: 40px;
		line-height: 0.8;
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	/* ---- pills ---- */

	.ssc__pill,
	.ssc__link {
		display: inline-flex;
		align-items: center;
		gap: 16px;
		min-height: 50px;
		padding: 5px 5px 5px 19px;
		border-radius: 100px;
		font: 600 14px/1 var(--ssc-body);
		text-decoration: none;
		transition: background-color 0.2s ease, border-color 0.2s ease;
	}

	.ssc__pill {
		background: var(--ssc-accent);
		color: #fff;
	}

	.ssc__pill-icon,
	.ssc__link-icon {
		display: grid;
		place-items: center;
		flex: none;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		transition: transform 0.25s ease;
	}

	.ssc__pill-icon {
		background: #fff;
		color: var(--ssc-accent);
	}

	.ssc__pill:hover .ssc__pill-icon,
	.ssc__link:hover .ssc__link-icon {
		transform: translateX(3px);
	}

	.ssc__pill:focus-visible,
	.ssc__link:focus-visible {
		outline: 2px solid #fff;
		outline-offset: 3px;
	}

	.ssc__card--light .ssc__link:focus-visible {
		outline-color: var(--ssc-navy);
	}

	/* ---- cards ---- */

	.ssc__list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	.ssc__card {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		padding: 24px;
		border-radius: 10px;
		transform-origin: 50% 50%;
	}

	.ssc__card--light {
		background: #fff;
		--ssc-c-num: rgba(10, 37, 64, 0.3);
		--ssc-c-title: var(--ssc-navy);
		--ssc-c-text: var(--ssc-ink-soft);
		--ssc-c-strong: var(--ssc-navy);
	}

	.ssc__card--accent {
		background: var(--ssc-accent);
		--ssc-c-num: rgba(255, 255, 255, 0.6);
		--ssc-c-title: #fff;
		--ssc-c-text: rgba(255, 255, 255, 0.85);
		--ssc-c-strong: #fff;
	}

	.ssc__body {
		display: flex;
		flex-direction: column;
		gap: 16px;
		width: 100%;
	}

	.ssc__num {
		margin: 0;
		color: var(--ssc-c-num);
		font: 400 48px/0.8 var(--ssc-display);
		letter-spacing: -0.02em;
	}

	.ssc__content {
		display: flex;
		flex-direction: column;
		gap: 24px;
	}

	.ssc__text {
		display: flex;
		flex-direction: column;
		gap: 10px;
	}

	.ssc__title {
		margin: 0;
		color: var(--ssc-c-title);
		font: 400 32px/1 var(--ssc-display);
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.ssc__desc,
	.ssc__checks {
		margin: 0;
		color: var(--ssc-c-text);
		font: 400 16px/1.6 var(--ssc-body);
	}

	.ssc__features {
		display: flex;
		flex-direction: column;
		gap: 20px;
	}

	.ssc__label {
		margin: 0;
		color: var(--ssc-c-strong);
		font: 500 14px/1 var(--ssc-body);
		text-transform: uppercase;
	}

	.ssc__checks {
		list-style: none;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 12px;
	}

	.ssc__checks li {
		display: flex;
		align-items: flex-start;
		gap: 6px;
	}

	.ssc__check {
		flex: none;
		margin-top: 3px;
		color: var(--ssc-c-strong);
	}

	.ssc__link {
		border: 1px solid;
	}

	.ssc__card--light .ssc__link {
		background: #fff;
		border-color: rgba(10, 37, 64, 0.14);
		color: var(--ssc-navy);
	}

	.ssc__card--light .ssc__link-icon {
		background: var(--ssc-navy);
		color: #fff;
	}

	.ssc__card--accent .ssc__link {
		background: transparent;
		border-color: rgba(255, 255, 255, 0.45);
		color: #fff;
	}

	.ssc__card--accent .ssc__link-icon {
		background: #fff;
		color: var(--ssc-accent);
	}

	.ssc__card--light .ssc__link:hover {
		border-color: var(--ssc-navy);
	}

	.ssc__card--accent .ssc__link:hover {
		background: rgba(255, 255, 255, 0.12);
	}

	.ssc__sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- tablet: same column, more room ---- */

	@media (min-width: 810px) {
		.ssc {
			padding: 100px 30px;
		}

		.ssc__intro {
			padding: 48px;
		}

		.ssc__heading {
			font-size: 72px;
		}

		.ssc__card {
			padding: 48px;
			gap: 40px;
		}

		.ssc__num {
			font-size: 96px;
		}

		.ssc__title {
			font-size: 40px;
		}
	}

	/* ---- desktop: two columns, sticky pile ---- */

	@media (min-width: 1024px) {
		.ssc {
			padding: 120px 30px;
		}

		.ssc__inner {
			display: grid;
			grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
			align-items: start;
			gap: 24px;
		}

		.ssc__intro {
			padding: clamp(48px, 5.5vw, 80px) clamp(48px, 5.5vw, 80px) 320px;
		}

		.ssc__intro-text {
			gap: 60px;
		}

		.ssc-ph__text {
			display: flex;
		}

		.ssc__heading {
			font-size: clamp(64px, 8.4vw, 120px);
		}

		.ssc__list {
			gap: 40px;
		}

		.ssc__card {
			padding: clamp(48px, 5.5vw, 80px);
			gap: 60px;
		}

		.ssc__body {
			gap: 24px;
		}

		.ssc__content {
			gap: 40px;
		}

		.ssc__features {
			gap: 24px;
		}

		.ssc__num {
			font-size: 120px;
		}

		.ssc__title {
			font-size: 48px;
		}

		[data-stack] .ssc__intro,
		[data-stack] .ssc__item {
			position: sticky;
			top: var(--ssc-top);
		}
	}

	/*
	 * The shrink. Card N scales on the view timeline of card N+1: it starts as
	 * N+1's top edge enters the viewport and finishes 400px later, well before
	 * N+1 reaches its sticky offset, so a covered card is always at rest. Where
	 * the browser lacks scroll-driven animations or `timeline-scope`, the cards
	 * still pile — they just don't shrink. Reduced motion never shrinks.
	 */
	@supports (animation-timeline: view()) and (timeline-scope: --ssc-a) {
		@media (min-width: 1024px) and (prefers-reduced-motion: no-preference) {
			[data-stack] .ssc__item {
				view-timeline-name: var(--ssc-self);
			}

			[data-shrink] .ssc__item--covered .ssc__card {
				animation: ssc-shrink linear both;
				animation-timeline: var(--ssc-next);
				animation-range: entry 0% entry 400px;
			}
		}
	}

	@keyframes ssc-shrink {
		from {
			transform: scale(1);
		}
		to {
			transform: scale(0.966);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.ssc__pill,
		.ssc__link,
		.ssc__pill-icon,
		.ssc__link-icon {
			transition: none;
		}

		.ssc__pill:hover .ssc__pill-icon,
		.ssc__link:hover .ssc__link-icon {
			transform: none;
		}
	}
</style>