Component library
Back to catalog

Service showcase

Alternating service rows pairing a window-framed media panel with an exclusive accordion and a call to action.

sectionaccordionmedia

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/service-showcase/

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: "Service showcase"
Library page: https://astro.baysixmedia.com/components/service-showcase/
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/ServiceShowcase.astro =====
---
/**
 * ServiceShowcase — a display heading over alternating service rows, each
 * pairing a window-framed media panel with a description, an exclusive
 * accordion of sub-items, and a call to action.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * The media panel is drawn as a small application window — a chrome bar with
 * three dots over a 16:9 stage. With no media supplied the stage paints a CSS
 * placeholder, so the component has nothing to download and still looks
 * deliberate; pass an image or a video per item to fill it.
 *
 * Measured behaviour:
 *   rows          media/text side alternates at >=768, stacks media-first below
 *   proportions   media 3fr / text 2fr at >=768
 *   window        16px radius, 1px border, 29px chrome bar, three 14px dots
 *   accordion     grid-template-rows 0fr -> 1fr over 300ms ease-out,
 *                 exclusive within its own list, chevron turns 180deg
 *   cta           pill and icon tile recolour together over 200ms linear,
 *                 icon tile scales to 1.04
 *   heading       rises 40px and fades in as the section arrives
 *
 * Several instances can share a page: element ids are seeded from a per
 * instance uid, and every listener is bound per accordion list, so two
 * showcases never open each other's rows.
 */

export interface ShowcaseDetail {
	/** Used for this row's element ids. Falls back to its position. */
	id?: string;
	/** The accordion row's label. */
	label: string;
	/** Text revealed when the row opens. */
	body?: string;
}

export interface ShowcaseMedia {
	/** Image or video URL. Anything the host can serve. */
	src: string;
	/** Treat `src` as a looping video rather than an image. */
	video?: boolean;
	/** Still shown before a video plays, and under reduced motion. */
	poster?: string;
	/** Alternative text. Required for an image that carries meaning. */
	alt?: string;
}

export interface ShowcaseCta {
	label: string;
	href: string;
	/** Open in a new tab, with the matching rel. */
	external?: boolean;
}

export interface ShowcaseItem {
	/** Used for the row's element ids. Falls back to its position. */
	id?: string;
	/** The service name, set in the large display size. */
	title: string;
	/** Paragraph under the title. */
	description?: string;
	/** Accordion rows. Omit for a row with no expandable list. */
	details?: ShowcaseDetail[];
	/** Fills the window's stage. Omit for the CSS placeholder. */
	media?: ShowcaseMedia;
	/** Caption drawn on the placeholder when there is no media. */
	placeholderLabel?: string;
	/** Button under the list. Omit for a row with no button. */
	cta?: ShowcaseCta;
}

interface Props {
	/** The display heading over the rows. Empty string hides it. */
	heading?: string;
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The service rows. */
	items?: ShowcaseItem[];
	/** Flip the media to the other side on every second row, at >=768. */
	alternate?: boolean;
	/** Open each row's first accordion item on load. */
	openFirst?: boolean;
	/** Section background. */
	surface?: string;
	/** Text colour. Muted tones and hairlines are derived from it. */
	ink?: string;
	/** Button and hover colour. */
	accent?: string;
	/** Deeper accent, used for the icon tile on hover. */
	accentDeep?: string;
	/** Soft coloured glows behind the rows. */
	glow?: boolean;
	/** Fine film grain over the section. */
	grain?: boolean;
	/** Fade and lift the heading in as the section arrives. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

const {
	heading = "What we do",
	headingLevel = "h2",
	items = [],
	alternate = true,
	openFirst = false,
	surface = "#0a0a0c",
	ink = "#ffffff",
	accent = "#4338ca",
	accentDeep = "#312c9c",
	glow = true,
	grain = true,
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* A row needs a title, and an accordion row a label; anything without one is
   dropped rather than drawn half-empty. No rows, no section. */
const rows = items
	.filter((item) => item?.title)
	.map((item) => ({ ...item, details: item.details?.filter((detail) => detail?.label) }));
const uid = id ?? `svc-${Math.random().toString(36).slice(2, 9)}`;
const itemKey = (item: ShowcaseItem, i: number) => item.id ?? String(i + 1);
const detailKey = (detail: ShowcaseDetail, i: number) => detail.id ?? String(i + 1);

/*
 * A self-drawn grain tile. feTurbulence gives a stable, tiny noise field with
 * no asset to ship and no request to make.
 */
const grainUrl =
	"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E\")";
---

{rows.length > 0 && (
<section
	class:list={["showcase", className]}
	id={id}
	data-showcase
	data-uid={uid}
	data-reveal={reveal ? "" : undefined}
	data-glow={glow ? "" : undefined}
	data-grain={grain ? "" : undefined}
	data-alternate={alternate ? "" : undefined}
	style={`--sv-surface:${surface};--sv-ink:${ink};--sv-accent:${accent};--sv-accent-deep:${accentDeep};--sv-grain:${grainUrl};`}
>
	{
		glow && (
			<div class="glows" aria-hidden="true">
				<span class="glow glow-a" />
				<span class="glow glow-b" />
				<span class="glow glow-c" />
			</div>
		)
	}

	<div class="inner">
		{
			heading && (
				<Heading class="heading" data-r-lift>
					{heading}
				</Heading>
			)
		}

		<div class="rows">
			{
				rows.map((item, i) => {
					const key = itemKey(item, i);
					return (
						<article class="row" id={item.id} data-row>
							<div class="media">
								<div class="chrome" aria-hidden="true">
									<span class="dot dot-1" />
									<span class="dot dot-2" />
									<span class="dot dot-3" />
								</div>
								<div class="stage">
									{item.media ? (
										item.media.video ? (
											<video
												class="fill"
												muted
												loop
												playsinline
												preload="none"
												poster={item.media.poster}
												aria-label={item.media.alt}
												data-video
											>
												<source src={item.media.src} />
											</video>
										) : (
											<img
												class="fill"
												src={item.media.src}
												alt={item.media.alt ?? ""}
												loading="lazy"
												decoding="async"
											/>
										)
									) : (
										<span class="placeholder" aria-hidden="true">
											{item.placeholderLabel ?? "16 : 9"}
										</span>
									)}
								</div>
							</div>

							<div class="copy">
								<div class="lede">
									<h3 class="title">{item.title}</h3>
									{item.description && <p class="description">{item.description}</p>}
								</div>

								{item.details && item.details.length > 0 && (
									<ul class="details" data-details>
										{item.details.map((detail, d) => {
											const dk = `${uid}-${key}-${detailKey(detail, d)}`;
											const open = openFirst && d === 0;
											return (
												<li class="detail" data-detail data-open={open ? "" : undefined}>
													<button
														type="button"
														class="detail-button"
														id={`${dk}-button`}
														aria-controls={`${dk}-panel`}
														aria-expanded={open ? "true" : "false"}
														data-detail-button
													>
														<span class="detail-label">{detail.label}</span>
														<svg
															class="chevron"
															viewBox="0 0 24 24"
															width="21"
															height="21"
															fill="none"
															stroke="currentColor"
															stroke-width="1.6"
															stroke-linecap="round"
															stroke-linejoin="round"
															aria-hidden="true"
														>
															<path d="m6 9 6 6 6-6" />
														</svg>
													</button>
													<div
														class="detail-panel"
														id={`${dk}-panel`}
														role="region"
														aria-labelledby={`${dk}-button`}
														data-detail-panel
													>
														<div class="detail-inner">
															{detail.body && <p class="detail-body">{detail.body}</p>}
														</div>
													</div>
												</li>
											);
										})}
									</ul>
								)}

								{item.cta && (
									<div class="cta-wrap">
										<a
											class="cta"
											href={item.cta.href}
											target={item.cta.external ? "_blank" : undefined}
											rel={item.cta.external ? "noopener noreferrer" : undefined}
										>
											<span class="cta-icon" aria-hidden="true">
												<svg
													viewBox="0 0 24 24"
													width="20"
													height="20"
													fill="none"
													stroke="currentColor"
													stroke-width="1.8"
													stroke-linecap="round"
													stroke-linejoin="round"
												>
													<path d="M7 17 17 7M9 7h8v8" />
												</svg>
											</span>
											<span class="cta-label">{item.cta.label}</span>
										</a>
									</div>
								)}
							</div>
						</article>
					);
				})
			}
		</div>
	</div>
</section>
)}

<style>
	.showcase {
		/* Every tone is derived from --sv-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--sv-muted: color-mix(in srgb, var(--sv-ink) 62%, transparent);
		--sv-hairline: color-mix(in srgb, var(--sv-ink) 20%, transparent);
		--sv-frame: color-mix(in srgb, var(--sv-ink) 28%, transparent);
		--sv-chrome: color-mix(in srgb, var(--sv-ink) 8%, var(--sv-surface));
		--sv-stage: color-mix(in srgb, var(--sv-ink) 4%, var(--sv-surface));

		--sv-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 46px at a 390px viewport, 140px from 1280px up. */
		--sv-display: clamp(2.9rem, 11vw, 8.75rem);
		--sv-title: clamp(2.5rem, 4.5vw, 3.5rem);
		--sv-body: 1rem;
		--sv-detail: 1.3125rem;

		--sv-gutter: clamp(24px, 3.4vw, 48px);
		--sv-maxw: 1440px;
		--sv-radius: 16px;

		box-sizing: border-box;
		position: relative;
		display: block;
		isolation: isolate;
		overflow: clip;
		padding-block: clamp(72px, 9vw, 128px);
		background: var(--sv-surface);
		color: var(--sv-ink);
		font-family: var(--sv-font);
	}

	.showcase :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/* Fine grain over the whole section, drawn rather than downloaded. */
	.showcase[data-grain]::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 3;
		pointer-events: none;
		opacity: 0.055;
		background-image: var(--sv-grain);
		background-repeat: repeat;
	}

	/* ---------- glows ---------- */

	.glows {
		position: absolute;
		inset: 0;
		z-index: 1;
		pointer-events: none;
		overflow: clip;
	}

	.glow {
		position: absolute;
		display: block;
		border-radius: 50%;
	}

	.glow-a {
		width: 1250px;
		height: 700px;
		top: -8%;
		left: -40%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--sv-accent) 0%,
			transparent 100%
		);
		opacity: 0.16;
	}

	.glow-b {
		width: 1250px;
		height: 700px;
		top: 42%;
		right: -34%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--sv-accent) 0%,
			transparent 100%
		);
		opacity: 0.12;
	}

	.glow-c {
		width: 1530px;
		height: 760px;
		top: 20%;
		left: -10%;
		background: radial-gradient(
			ellipse farthest-side at center,
			color-mix(in srgb, var(--sv-ink) 5%, transparent) 0%,
			transparent 100%
		);
	}

	.inner {
		position: relative;
		z-index: 2;
		max-width: var(--sv-maxw);
		margin-inline: auto;
		padding-inline: var(--sv-gutter);
	}

	/* ---------- heading ---------- */

	.heading {
		margin: 0 0 40px;
		font-size: var(--sv-display);
		line-height: 1;
		font-weight: 650;
		letter-spacing: -0.02em;
		text-transform: uppercase;
		text-align: center;
		text-wrap: balance;
	}

	.showcase[data-reveal] [data-r-lift] {
		opacity: 0;
		transform: translateY(40px);
		transition:
			opacity 800ms cubic-bezier(0.16, 1, 0.3, 1),
			transform 800ms cubic-bezier(0.16, 1, 0.3, 1);
	}

	.showcase[data-reveal] [data-r-lift][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- rows ---------- */

	.rows {
		display: flex;
		flex-direction: column;
		gap: 64px;
	}

	.row {
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	/* ---------- window-framed media ---------- */

	.media {
		display: flex;
		flex-direction: column;
		width: 100%;
		overflow: hidden;
		border: 1px solid var(--sv-frame);
		border-radius: var(--sv-radius);
	}

	.chrome {
		position: relative;
		display: flex;
		align-items: center;
		gap: 8px;
		height: 29px;
		flex-shrink: 0;
		padding-inline: 10px;
		background: var(--sv-chrome);
	}

	.dot {
		width: 11px;
		height: 11px;
		border-radius: 50%;
	}

	/* The three dots are the one place a fixed hue is right: they read as a
	   window control only in these colours. */
	.dot-1 {
		background: #ff5f57;
	}

	.dot-2 {
		background: #febc2e;
	}

	.dot-3 {
		background: #28c840;
	}

	.stage {
		position: relative;
		display: grid;
		place-items: center;
		aspect-ratio: 16 / 9;
		width: 100%;
		overflow: hidden;
		background: var(--sv-stage);
	}

	.fill {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Shown when an item carries no media: a drawn stand-in, not a broken box. */
	.placeholder {
		position: relative;
		font-size: 0.8125rem;
		font-weight: 500;
		letter-spacing: 0.32em;
		color: color-mix(in srgb, var(--sv-ink) 38%, transparent);
	}

	.stage:has(.placeholder)::before {
		content: "";
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				ellipse 70% 60% at 50% 45%,
				color-mix(in srgb, var(--sv-accent) 26%, transparent) 0%,
				transparent 70%
			),
			repeating-linear-gradient(
				to bottom,
				color-mix(in srgb, var(--sv-ink) 4%, transparent) 0 1px,
				transparent 1px 5px
			);
	}

	/* ---------- copy ---------- */

	.copy {
		display: flex;
		flex-direction: column;
		gap: 32px;
		padding-inline: 8px;
	}

	.lede {
		display: flex;
		flex-direction: column;
		gap: 8px;
	}

	.title {
		margin: 0;
		font-size: var(--sv-title);
		line-height: 1.1;
		font-weight: 650;
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.description {
		margin: 0;
		max-width: 46ch;
		font-size: var(--sv-body);
		line-height: 1.5;
		color: var(--sv-muted);
		text-wrap: pretty;
	}

	/* ---------- accordion ---------- */

	.details {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
	}

	.detail {
		border-bottom: 1px solid var(--sv-hairline);
	}

	.detail-button {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		width: 100%;
		padding-block: 12px;
		margin: 0;
		font: inherit;
		font-size: var(--sv-detail);
		font-weight: 650;
		line-height: 1.4;
		letter-spacing: -0.01em;
		text-transform: uppercase;
		text-align: left;
		color: inherit;
		background: none;
		border: 0;
		cursor: pointer;
	}

	.detail-label {
		min-width: 0;
	}

	.chevron {
		flex-shrink: 0;
		color: var(--sv-muted);
		transition: rotate 300ms ease-out;
	}

	.detail[data-open] .chevron {
		rotate: 180deg;
	}

	/*
	 * The 0fr -> 1fr grid row is what makes this animate to the content's own
	 * height without anyone measuring it. The inner element must own the
	 * overflow, or the text spills while the row is collapsed.
	 */
	.detail-panel {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows 300ms ease-out;
	}

	.detail[data-open] .detail-panel {
		grid-template-rows: 1fr;
	}

	.detail-inner {
		overflow: hidden;
	}

	.detail-body {
		margin: 0;
		max-width: 52ch;
		padding-bottom: 16px;
		font-size: var(--sv-body);
		line-height: 1.6;
		color: var(--sv-muted);
		text-wrap: pretty;
	}

	/* ---------- call to action ---------- */

	.cta {
		display: inline-flex;
		align-items: center;
		gap: 16px;
		max-width: 100%;
		padding: 4px 20px 4px 4px;
		border: 1px solid color-mix(in srgb, #000 10%, transparent);
		border-radius: 8px;
		background: var(--sv-ink);
		text-decoration: none;
		transition:
			background-color 200ms linear,
			border-color 200ms linear;
	}

	.cta-icon {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 40px;
		height: 40px;
		flex-shrink: 0;
		border-radius: 4px;
		background: color-mix(in srgb, var(--sv-surface) 14%, #dcdce4);
		color: var(--sv-surface);
		transition:
			background-color 200ms linear,
			color 200ms linear,
			scale 200ms linear;
	}

	.cta-label {
		min-width: 0;
		font-size: 0.875rem;
		font-weight: 650;
		line-height: 1.15;
		letter-spacing: 0.01em;
		text-transform: uppercase;
		color: var(--sv-surface);
		transition: color 200ms linear;
	}

	.cta:focus-visible {
		outline: 2px solid var(--sv-ink);
		outline-offset: 4px;
	}

	.detail-button:focus-visible {
		outline: 2px solid var(--sv-ink);
		outline-offset: -2px;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.showcase {
			--sv-gutter: clamp(32px, 3.4vw, 48px);
		}

		.heading {
			margin-bottom: 56px;
		}

		.rows {
			gap: 96px;
		}

		.row {
			flex-direction: row;
			align-items: center;
		}

		/* Media takes the wider share; the copy column sits beside it. */
		.media {
			flex: 3;
			align-self: center;
		}

		.copy {
			flex: 2;
			gap: 48px;
			padding: 32px;
		}

		.showcase[data-alternate] .row:nth-child(even) {
			flex-direction: row-reverse;
		}

		.cta {
			padding-right: 28px;
			gap: 24px;
		}

		.cta-icon {
			width: 64px;
			height: 64px;
			border-radius: 6px;
		}

		.cta-label {
			font-size: 1.125rem;
			line-height: 1;
			white-space: nowrap;
		}
	}

	@media (min-width: 1024px) {
		.copy {
			padding-inline: 64px;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would leave a button stranded in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		.cta:hover {
			background: var(--sv-accent);
			border-color: transparent;
		}

		.cta:hover .cta-icon {
			background: var(--sv-accent-deep);
			color: var(--sv-ink);
			scale: 1.04;
		}

		.cta:hover .cta-label {
			color: var(--sv-ink);
		}

		.detail-button:hover .chevron {
			color: var(--sv-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.showcase[data-reveal] [data-r-lift] {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.detail-panel,
		.chevron,
		.cta,
		.cta-icon,
		.cta-label {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		/* Without scripting the rows cannot be opened, so show every panel and
		   drop the affordance that implies otherwise. */
		.showcase .detail-panel {
			grid-template-rows: 1fr;
		}

		.showcase .chevron {
			display: none;
		}

		.showcase[data-reveal] [data-r-lift] {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Three behaviours, all bound per instance so that two showcases on a page
	 * never reach into each other: the accordion, the heading reveal, and
	 * play-on-visible for any video.
	 */

	const reduceMotion = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	/* ---- accordion ---- */

	const setupAccordion = (root: HTMLElement) => {
		/* Each list is its own exclusive group. Scoping the query to the list
		   rather than the root is what lets one row stay open while another
		   row's list is untouched. */
		for (const list of root.querySelectorAll<HTMLElement>("[data-details]")) {
			const items = [...list.querySelectorAll<HTMLElement>("[data-detail]")];
			if (items.length === 0) continue;

			const setOpen = (item: HTMLElement, open: boolean) => {
				const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
				const panel = item.querySelector<HTMLElement>("[data-detail-panel]");
				if (!button || !panel) return;
				item.toggleAttribute("data-open", open);
				button.setAttribute("aria-expanded", open ? "true" : "false");
				/* A collapsed panel is still in the accessibility tree and still
				   focusable; `inert` takes it out of both without disturbing the
				   height transition. */
				panel.inert = !open;
			};

			for (const item of items) setOpen(item, item.hasAttribute("data-open"));

			for (const item of items) {
				const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
				if (!button) continue;

				button.addEventListener("click", () => {
					const willOpen = !item.hasAttribute("data-open");
					for (const other of items) setOpen(other, false);
					if (willOpen) setOpen(item, true);
				});
			}
		}
	};

	/* ---- heading reveal ---- */

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const targets = [...root.querySelectorAll<HTMLElement>("[data-r-lift]")];
		if (targets.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			targets.forEach((target) => target.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The bottom margin sets the trigger point. The large top margin is
			 * what makes a jump safe: an observer reports only a *change* in
			 * intersection, so a heading that goes from below the viewport to
			 * above it in one jump — an anchor link, a restored scroll position
			 * — would read 0 both times, never fire, and stay invisible for
			 * good. Extending the root upwards means "already scrolled past"
			 * still counts as intersecting.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		targets.forEach((target) => observer.observe(target));
	};

	/* ---- play videos only while they are on screen ---- */

	const setupVideos = (root: HTMLElement) => {
		const videos = [...root.querySelectorAll<HTMLVideoElement>("[data-video]")];
		if (videos.length === 0) return;

		/* An autoplaying loop is motion the reader did not ask for. Under a
		   reduced-motion preference the poster stands in for it. */
		if (reduceMotion() || !("IntersectionObserver" in window)) return;

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					const video = entry.target as HTMLVideoElement;
					if (entry.isIntersecting) {
						/* A rejected play() is normal — a background tab, or a
						   policy that wants a gesture first. The poster stays. */
						video.play().catch(() => {});
					} else {
						video.pause();
					}
				}
			},
			{ threshold: 0.25 },
		);

		videos.forEach((video) => observer.observe(video));
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-showcase]")) {
		setupAccordion(root);
		setupReveal(root);
		setupVideos(root);
	}
</script>

===== END src/components/ServiceShowcase.astro =====

===== FILE (supporting): src/components/ServiceShowcase.md =====
# Service showcase

A large display heading over alternating service rows. Each row pairs a
window-framed media panel with a title, a description, an exclusive accordion
of sub-items, and a call to action. The media side flips on every second row,
and everything stacks media-first on a phone.

Catalog page: `/components/service-showcase`

## Copying it into another Astro project

Copy **`ServiceShowcase.astro`** — one file, the whole component. There is
nothing else to move.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--sv-*` property on the root |
| Webfonts | none — system stack |
| Bundled images, video or icons | **none** — see below |
| Network requests on load | none, unless you pass media |
| Host requirement | Astro compiles `.astro` files |

Three things that would normally be assets are drawn instead:

- the **film grain** is an inline `feTurbulence` SVG in a `data:` URI
- the **media placeholder** is a CSS gradient, shown whenever an item has no
  `media`
- the **chevron and arrow icons** are inline `<svg>` paths

So the component ships with zero files beside it. Pass `media` on an item and
*that* file becomes your dependency — nothing else does.

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

<ServiceShowcase
  heading="What we do"
  items={[
    {
      title: "Interface",
      description: "Screens, states and the motion between them.",
      details: [
        { label: "Design systems", body: "Tokens, components and the rules that keep them honest." },
        { label: "Prototypes", body: "Clickable builds at real fidelity." },
      ],
      cta: { label: "See the work", href: "/interface" },
    },
  ]}
/>
```

Every prop is optional, but the rows are your content: `items` defaults to
empty, and with no rows the component renders nothing — the heading alone never
shows. A row with no `title`, or an accordion row with no `label`, is dropped.
The two-row demo on the catalog page is passed in as props.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `ShowcaseItem[]` | `[]` | The service rows. Any count works; none renders nothing. |
| `heading` | `string` | `"What we do"` | Display heading over the rows. `""` hides it. Shown only with rows. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `alternate` | `boolean` | `true` | Flip the media side on every second row, at ≥768. |
| `openFirst` | `boolean` | `false` | Open each row's first accordion item on load. |
| `surface` | `string` | `"#0a0a0c"` | Section background. |
| `ink` | `string` | `"#ffffff"` | Text colour. Muted tones and hairlines derive from it. |
| `accent` | `string` | `"#4338ca"` | Button hover colour. |
| `accentDeep` | `string` | `"#312c9c"` | Icon tile colour on hover. |
| `glow` | `boolean` | `true` | Soft coloured glows behind the rows. |
| `grain` | `boolean` | `true` | Fine film grain over the section. |
| `reveal` | `boolean` | `true` | Fade and lift the heading in as the section arrives. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | auto | Root element id. Also seeds the internal element ids. |

### `ShowcaseItem` and friends

```ts
interface ShowcaseItem {
  id?: string;                  // seeds this row's element ids
  title: string;                // service name, large display size
  description?: string;         // paragraph under the title
  details?: ShowcaseDetail[];   // the accordion rows
  media?: ShowcaseMedia;        // omit for the CSS placeholder
  placeholderLabel?: string;    // caption drawn on the placeholder
  cta?: ShowcaseCta;            // omit for a row with no button
}

interface ShowcaseDetail {
  id?: string;
  label: string;                // the accordion row's label
  body?: string;                // text revealed when it opens
}

interface ShowcaseMedia {
  src: string;                  // image or video URL
  video?: boolean;              // treat src as a looping video
  poster?: string;              // still shown before play, and under reduced motion
  alt?: string;
}

interface ShowcaseCta {
  label: string;
  href: string;
  external?: boolean;           // opens in a new tab with rel="noopener noreferrer"
}
```

`ink` and `surface` re-tint the section; the muted, hairline, frame, chrome and
stage tones are all `color-mix()` derivations, so one override moves them
together. The three window dots keep fixed hues on purpose — they only read as
window controls in red, amber and green.

## Layout

| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Row direction | row, alternating | row, alternating | column, media first |
| Media / copy share | 3 / 2 | 3 / 2 | full width |
| Row gap | 96px | 96px | 64px |
| Copy padding | 32px, 64px inline | 32px | 8px inline |
| Heading | 140px | ~92px | ~46px |
| Service title | 56px | 40px | 40px |
| Accordion label | 21px | 21px | 21px |
| CTA icon / label | 64px / 18px | 64px / 18px | 40px / 14px |
| Gutter | `clamp(32px, 3.4vw, 48px)` | ← | 24px |

The media panel is drawn as a small application window: a 29px chrome bar with
three 11px dots over a 16:9 stage, inside a 16px radius and a 1px frame.

The copy column's share is `flex: 2` against the media's `flex: 3`, but a long
uppercase label can push it wider than that — a flex item will not shrink below
its min-content width. The reference behaves the same way; if you want the
ratio held strictly, add `min-width: 0` to `.copy`.

## Behaviour

- **Accordion.** One row open at a time **within its own list**; opening a
  second closes the first, and clicking the open one closes it. The panel
  animates with `grid-template-rows: 0fr → 1fr` over `300ms ease-out`, which
  reaches the content's own height without anything measuring it. The chevron
  turns 180°.
- **Call to action.** The pill, its icon tile and its label recolour together
  over `200ms linear`, and the tile scales to `1.04`. Gated behind
  `(hover: hover) and (pointer: fine)`, because on a touch screen `:hover`
  sticks after the finger lifts.
- **Heading reveal.** Rises 40px and fades in as the section arrives, once.
- **Video.** Any `media.video` item is `preload="none"` and plays only while it
  is at least 25% on screen, pausing when it leaves. A rejected `play()` is
  swallowed — a background tab or an autoplay policy simply leaves the poster
  up.
- **Several per page.** Element ids are seeded from a per-instance uid, and
  listeners are bound per accordion list, so two showcases never open each
  other's rows and never collide on an id.

### Accessibility

- Each accordion row is a real `<button>` with `aria-expanded`, wired to its
  panel by `aria-controls`; the panel points back with `aria-labelledby`.
- A collapsed panel is set `inert`, so its text is out of both the tab order
  and the accessibility tree — a zero-height `overflow: hidden` box otherwise
  leaves the content readable by a screen reader while invisible on screen.
- Focus rings are explicit on both the buttons and the CTA.
- The dots, chevrons, arrow and placeholder are `aria-hidden`.

### Reduced motion

Under `prefers-reduced-motion: reduce`:

- the heading is shown immediately, with no lift or fade
- the accordion opens and closes instantly — it still works, it just does not
  animate
- the CTA and chevron transitions are removed
- **videos do not play.** An autoplaying loop is motion the reader did not ask
  for, so the poster stands in for it.

### Without JavaScript

The pre-open state is CSS, so the first paint is already correct and nothing
flashes. A `<noscript>` block then opens every accordion panel and hides the
chevrons, because a row that cannot be opened should not advertise that it can.
All the text stays readable.

## Notes on the reference

The layout idea — a display heading over alternating rows, media framed as an
application window, an accordion of sub-items, a pill button with an icon tile
— was studied from a live marketing page. **None of that site's content came
with it.** The copy, the service names, the palette, the placeholder and the
grain are all written or drawn here; no text, logo, client name, photograph,
video or brand colour was copied. Replace `items` and `heading` with your own
and the design is unchanged.

===== END src/components/ServiceShowcase.md =====

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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 showcase” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-showcase/

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.

src/components/library/ServiceShowcase.astro

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

---
/**
 * ServiceShowcase — a display heading over alternating service rows, each
 * pairing a window-framed media panel with a description, an exclusive
 * accordion of sub-items, and a call to action.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * The media panel is drawn as a small application window — a chrome bar with
 * three dots over a 16:9 stage. With no media supplied the stage paints a CSS
 * placeholder, so the component has nothing to download and still looks
 * deliberate; pass an image or a video per item to fill it.
 *
 * Measured behaviour:
 *   rows          media/text side alternates at >=768, stacks media-first below
 *   proportions   media 3fr / text 2fr at >=768
 *   window        16px radius, 1px border, 29px chrome bar, three 14px dots
 *   accordion     grid-template-rows 0fr -> 1fr over 300ms ease-out,
 *                 exclusive within its own list, chevron turns 180deg
 *   cta           pill and icon tile recolour together over 200ms linear,
 *                 icon tile scales to 1.04
 *   heading       rises 40px and fades in as the section arrives
 *
 * Several instances can share a page: element ids are seeded from a per
 * instance uid, and every listener is bound per accordion list, so two
 * showcases never open each other's rows.
 */

export interface ShowcaseDetail {
	/** Used for this row's element ids. Falls back to its position. */
	id?: string;
	/** The accordion row's label. */
	label: string;
	/** Text revealed when the row opens. */
	body?: string;
}

export interface ShowcaseMedia {
	/** Image or video URL. Anything the host can serve. */
	src: string;
	/** Treat `src` as a looping video rather than an image. */
	video?: boolean;
	/** Still shown before a video plays, and under reduced motion. */
	poster?: string;
	/** Alternative text. Required for an image that carries meaning. */
	alt?: string;
}

export interface ShowcaseCta {
	label: string;
	href: string;
	/** Open in a new tab, with the matching rel. */
	external?: boolean;
}

export interface ShowcaseItem {
	/** Used for the row's element ids. Falls back to its position. */
	id?: string;
	/** The service name, set in the large display size. */
	title: string;
	/** Paragraph under the title. */
	description?: string;
	/** Accordion rows. Omit for a row with no expandable list. */
	details?: ShowcaseDetail[];
	/** Fills the window's stage. Omit for the CSS placeholder. */
	media?: ShowcaseMedia;
	/** Caption drawn on the placeholder when there is no media. */
	placeholderLabel?: string;
	/** Button under the list. Omit for a row with no button. */
	cta?: ShowcaseCta;
}

interface Props {
	/** The display heading over the rows. Empty string hides it. */
	heading?: string;
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The service rows. */
	items?: ShowcaseItem[];
	/** Flip the media to the other side on every second row, at >=768. */
	alternate?: boolean;
	/** Open each row's first accordion item on load. */
	openFirst?: boolean;
	/** Section background. */
	surface?: string;
	/** Text colour. Muted tones and hairlines are derived from it. */
	ink?: string;
	/** Button and hover colour. */
	accent?: string;
	/** Deeper accent, used for the icon tile on hover. */
	accentDeep?: string;
	/** Soft coloured glows behind the rows. */
	glow?: boolean;
	/** Fine film grain over the section. */
	grain?: boolean;
	/** Fade and lift the heading in as the section arrives. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

const {
	heading = "What we do",
	headingLevel = "h2",
	items = [],
	alternate = true,
	openFirst = false,
	surface = "#0a0a0c",
	ink = "#ffffff",
	accent = "#4338ca",
	accentDeep = "#312c9c",
	glow = true,
	grain = true,
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* A row needs a title, and an accordion row a label; anything without one is
   dropped rather than drawn half-empty. No rows, no section. */
const rows = items
	.filter((item) => item?.title)
	.map((item) => ({ ...item, details: item.details?.filter((detail) => detail?.label) }));
const uid = id ?? `svc-${Math.random().toString(36).slice(2, 9)}`;
const itemKey = (item: ShowcaseItem, i: number) => item.id ?? String(i + 1);
const detailKey = (detail: ShowcaseDetail, i: number) => detail.id ?? String(i + 1);

/*
 * A self-drawn grain tile. feTurbulence gives a stable, tiny noise field with
 * no asset to ship and no request to make.
 */
const grainUrl =
	"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E\")";
---

{rows.length > 0 && (
<section
	class:list={["showcase", className]}
	id={id}
	data-showcase
	data-uid={uid}
	data-reveal={reveal ? "" : undefined}
	data-glow={glow ? "" : undefined}
	data-grain={grain ? "" : undefined}
	data-alternate={alternate ? "" : undefined}
	style={`--sv-surface:${surface};--sv-ink:${ink};--sv-accent:${accent};--sv-accent-deep:${accentDeep};--sv-grain:${grainUrl};`}
>
	{
		glow && (
			<div class="glows" aria-hidden="true">
				<span class="glow glow-a" />
				<span class="glow glow-b" />
				<span class="glow glow-c" />
			</div>
		)
	}

	<div class="inner">
		{
			heading && (
				<Heading class="heading" data-r-lift>
					{heading}
				</Heading>
			)
		}

		<div class="rows">
			{
				rows.map((item, i) => {
					const key = itemKey(item, i);
					return (
						<article class="row" id={item.id} data-row>
							<div class="media">
								<div class="chrome" aria-hidden="true">
									<span class="dot dot-1" />
									<span class="dot dot-2" />
									<span class="dot dot-3" />
								</div>
								<div class="stage">
									{item.media ? (
										item.media.video ? (
											<video
												class="fill"
												muted
												loop
												playsinline
												preload="none"
												poster={item.media.poster}
												aria-label={item.media.alt}
												data-video
											>
												<source src={item.media.src} />
											</video>
										) : (
											<img
												class="fill"
												src={item.media.src}
												alt={item.media.alt ?? ""}
												loading="lazy"
												decoding="async"
											/>
										)
									) : (
										<span class="placeholder" aria-hidden="true">
											{item.placeholderLabel ?? "16 : 9"}
										</span>
									)}
								</div>
							</div>

							<div class="copy">
								<div class="lede">
									<h3 class="title">{item.title}</h3>
									{item.description && <p class="description">{item.description}</p>}
								</div>

								{item.details && item.details.length > 0 && (
									<ul class="details" data-details>
										{item.details.map((detail, d) => {
											const dk = `${uid}-${key}-${detailKey(detail, d)}`;
											const open = openFirst && d === 0;
											return (
												<li class="detail" data-detail data-open={open ? "" : undefined}>
													<button
														type="button"
														class="detail-button"
														id={`${dk}-button`}
														aria-controls={`${dk}-panel`}
														aria-expanded={open ? "true" : "false"}
														data-detail-button
													>
														<span class="detail-label">{detail.label}</span>
														<svg
															class="chevron"
															viewBox="0 0 24 24"
															width="21"
															height="21"
															fill="none"
															stroke="currentColor"
															stroke-width="1.6"
															stroke-linecap="round"
															stroke-linejoin="round"
															aria-hidden="true"
														>
															<path d="m6 9 6 6 6-6" />
														</svg>
													</button>
													<div
														class="detail-panel"
														id={`${dk}-panel`}
														role="region"
														aria-labelledby={`${dk}-button`}
														data-detail-panel
													>
														<div class="detail-inner">
															{detail.body && <p class="detail-body">{detail.body}</p>}
														</div>
													</div>
												</li>
											);
										})}
									</ul>
								)}

								{item.cta && (
									<div class="cta-wrap">
										<a
											class="cta"
											href={item.cta.href}
											target={item.cta.external ? "_blank" : undefined}
											rel={item.cta.external ? "noopener noreferrer" : undefined}
										>
											<span class="cta-icon" aria-hidden="true">
												<svg
													viewBox="0 0 24 24"
													width="20"
													height="20"
													fill="none"
													stroke="currentColor"
													stroke-width="1.8"
													stroke-linecap="round"
													stroke-linejoin="round"
												>
													<path d="M7 17 17 7M9 7h8v8" />
												</svg>
											</span>
											<span class="cta-label">{item.cta.label}</span>
										</a>
									</div>
								)}
							</div>
						</article>
					);
				})
			}
		</div>
	</div>
</section>
)}

<style>
	.showcase {
		/* Every tone is derived from --sv-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--sv-muted: color-mix(in srgb, var(--sv-ink) 62%, transparent);
		--sv-hairline: color-mix(in srgb, var(--sv-ink) 20%, transparent);
		--sv-frame: color-mix(in srgb, var(--sv-ink) 28%, transparent);
		--sv-chrome: color-mix(in srgb, var(--sv-ink) 8%, var(--sv-surface));
		--sv-stage: color-mix(in srgb, var(--sv-ink) 4%, var(--sv-surface));

		--sv-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 46px at a 390px viewport, 140px from 1280px up. */
		--sv-display: clamp(2.9rem, 11vw, 8.75rem);
		--sv-title: clamp(2.5rem, 4.5vw, 3.5rem);
		--sv-body: 1rem;
		--sv-detail: 1.3125rem;

		--sv-gutter: clamp(24px, 3.4vw, 48px);
		--sv-maxw: 1440px;
		--sv-radius: 16px;

		box-sizing: border-box;
		position: relative;
		display: block;
		isolation: isolate;
		overflow: clip;
		padding-block: clamp(72px, 9vw, 128px);
		background: var(--sv-surface);
		color: var(--sv-ink);
		font-family: var(--sv-font);
	}

	.showcase :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/* Fine grain over the whole section, drawn rather than downloaded. */
	.showcase[data-grain]::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 3;
		pointer-events: none;
		opacity: 0.055;
		background-image: var(--sv-grain);
		background-repeat: repeat;
	}

	/* ---------- glows ---------- */

	.glows {
		position: absolute;
		inset: 0;
		z-index: 1;
		pointer-events: none;
		overflow: clip;
	}

	.glow {
		position: absolute;
		display: block;
		border-radius: 50%;
	}

	.glow-a {
		width: 1250px;
		height: 700px;
		top: -8%;
		left: -40%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--sv-accent) 0%,
			transparent 100%
		);
		opacity: 0.16;
	}

	.glow-b {
		width: 1250px;
		height: 700px;
		top: 42%;
		right: -34%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--sv-accent) 0%,
			transparent 100%
		);
		opacity: 0.12;
	}

	.glow-c {
		width: 1530px;
		height: 760px;
		top: 20%;
		left: -10%;
		background: radial-gradient(
			ellipse farthest-side at center,
			color-mix(in srgb, var(--sv-ink) 5%, transparent) 0%,
			transparent 100%
		);
	}

	.inner {
		position: relative;
		z-index: 2;
		max-width: var(--sv-maxw);
		margin-inline: auto;
		padding-inline: var(--sv-gutter);
	}

	/* ---------- heading ---------- */

	.heading {
		margin: 0 0 40px;
		font-size: var(--sv-display);
		line-height: 1;
		font-weight: 650;
		letter-spacing: -0.02em;
		text-transform: uppercase;
		text-align: center;
		text-wrap: balance;
	}

	.showcase[data-reveal] [data-r-lift] {
		opacity: 0;
		transform: translateY(40px);
		transition:
			opacity 800ms cubic-bezier(0.16, 1, 0.3, 1),
			transform 800ms cubic-bezier(0.16, 1, 0.3, 1);
	}

	.showcase[data-reveal] [data-r-lift][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- rows ---------- */

	.rows {
		display: flex;
		flex-direction: column;
		gap: 64px;
	}

	.row {
		display: flex;
		flex-direction: column;
		gap: 32px;
	}

	/* ---------- window-framed media ---------- */

	.media {
		display: flex;
		flex-direction: column;
		width: 100%;
		overflow: hidden;
		border: 1px solid var(--sv-frame);
		border-radius: var(--sv-radius);
	}

	.chrome {
		position: relative;
		display: flex;
		align-items: center;
		gap: 8px;
		height: 29px;
		flex-shrink: 0;
		padding-inline: 10px;
		background: var(--sv-chrome);
	}

	.dot {
		width: 11px;
		height: 11px;
		border-radius: 50%;
	}

	/* The three dots are the one place a fixed hue is right: they read as a
	   window control only in these colours. */
	.dot-1 {
		background: #ff5f57;
	}

	.dot-2 {
		background: #febc2e;
	}

	.dot-3 {
		background: #28c840;
	}

	.stage {
		position: relative;
		display: grid;
		place-items: center;
		aspect-ratio: 16 / 9;
		width: 100%;
		overflow: hidden;
		background: var(--sv-stage);
	}

	.fill {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Shown when an item carries no media: a drawn stand-in, not a broken box. */
	.placeholder {
		position: relative;
		font-size: 0.8125rem;
		font-weight: 500;
		letter-spacing: 0.32em;
		color: color-mix(in srgb, var(--sv-ink) 38%, transparent);
	}

	.stage:has(.placeholder)::before {
		content: "";
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				ellipse 70% 60% at 50% 45%,
				color-mix(in srgb, var(--sv-accent) 26%, transparent) 0%,
				transparent 70%
			),
			repeating-linear-gradient(
				to bottom,
				color-mix(in srgb, var(--sv-ink) 4%, transparent) 0 1px,
				transparent 1px 5px
			);
	}

	/* ---------- copy ---------- */

	.copy {
		display: flex;
		flex-direction: column;
		gap: 32px;
		padding-inline: 8px;
	}

	.lede {
		display: flex;
		flex-direction: column;
		gap: 8px;
	}

	.title {
		margin: 0;
		font-size: var(--sv-title);
		line-height: 1.1;
		font-weight: 650;
		letter-spacing: -0.02em;
		text-transform: uppercase;
	}

	.description {
		margin: 0;
		max-width: 46ch;
		font-size: var(--sv-body);
		line-height: 1.5;
		color: var(--sv-muted);
		text-wrap: pretty;
	}

	/* ---------- accordion ---------- */

	.details {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
	}

	.detail {
		border-bottom: 1px solid var(--sv-hairline);
	}

	.detail-button {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		width: 100%;
		padding-block: 12px;
		margin: 0;
		font: inherit;
		font-size: var(--sv-detail);
		font-weight: 650;
		line-height: 1.4;
		letter-spacing: -0.01em;
		text-transform: uppercase;
		text-align: left;
		color: inherit;
		background: none;
		border: 0;
		cursor: pointer;
	}

	.detail-label {
		min-width: 0;
	}

	.chevron {
		flex-shrink: 0;
		color: var(--sv-muted);
		transition: rotate 300ms ease-out;
	}

	.detail[data-open] .chevron {
		rotate: 180deg;
	}

	/*
	 * The 0fr -> 1fr grid row is what makes this animate to the content's own
	 * height without anyone measuring it. The inner element must own the
	 * overflow, or the text spills while the row is collapsed.
	 */
	.detail-panel {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows 300ms ease-out;
	}

	.detail[data-open] .detail-panel {
		grid-template-rows: 1fr;
	}

	.detail-inner {
		overflow: hidden;
	}

	.detail-body {
		margin: 0;
		max-width: 52ch;
		padding-bottom: 16px;
		font-size: var(--sv-body);
		line-height: 1.6;
		color: var(--sv-muted);
		text-wrap: pretty;
	}

	/* ---------- call to action ---------- */

	.cta {
		display: inline-flex;
		align-items: center;
		gap: 16px;
		max-width: 100%;
		padding: 4px 20px 4px 4px;
		border: 1px solid color-mix(in srgb, #000 10%, transparent);
		border-radius: 8px;
		background: var(--sv-ink);
		text-decoration: none;
		transition:
			background-color 200ms linear,
			border-color 200ms linear;
	}

	.cta-icon {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 40px;
		height: 40px;
		flex-shrink: 0;
		border-radius: 4px;
		background: color-mix(in srgb, var(--sv-surface) 14%, #dcdce4);
		color: var(--sv-surface);
		transition:
			background-color 200ms linear,
			color 200ms linear,
			scale 200ms linear;
	}

	.cta-label {
		min-width: 0;
		font-size: 0.875rem;
		font-weight: 650;
		line-height: 1.15;
		letter-spacing: 0.01em;
		text-transform: uppercase;
		color: var(--sv-surface);
		transition: color 200ms linear;
	}

	.cta:focus-visible {
		outline: 2px solid var(--sv-ink);
		outline-offset: 4px;
	}

	.detail-button:focus-visible {
		outline: 2px solid var(--sv-ink);
		outline-offset: -2px;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.showcase {
			--sv-gutter: clamp(32px, 3.4vw, 48px);
		}

		.heading {
			margin-bottom: 56px;
		}

		.rows {
			gap: 96px;
		}

		.row {
			flex-direction: row;
			align-items: center;
		}

		/* Media takes the wider share; the copy column sits beside it. */
		.media {
			flex: 3;
			align-self: center;
		}

		.copy {
			flex: 2;
			gap: 48px;
			padding: 32px;
		}

		.showcase[data-alternate] .row:nth-child(even) {
			flex-direction: row-reverse;
		}

		.cta {
			padding-right: 28px;
			gap: 24px;
		}

		.cta-icon {
			width: 64px;
			height: 64px;
			border-radius: 6px;
		}

		.cta-label {
			font-size: 1.125rem;
			line-height: 1;
			white-space: nowrap;
		}
	}

	@media (min-width: 1024px) {
		.copy {
			padding-inline: 64px;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would leave a button stranded in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		.cta:hover {
			background: var(--sv-accent);
			border-color: transparent;
		}

		.cta:hover .cta-icon {
			background: var(--sv-accent-deep);
			color: var(--sv-ink);
			scale: 1.04;
		}

		.cta:hover .cta-label {
			color: var(--sv-ink);
		}

		.detail-button:hover .chevron {
			color: var(--sv-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.showcase[data-reveal] [data-r-lift] {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.detail-panel,
		.chevron,
		.cta,
		.cta-icon,
		.cta-label {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		/* Without scripting the rows cannot be opened, so show every panel and
		   drop the affordance that implies otherwise. */
		.showcase .detail-panel {
			grid-template-rows: 1fr;
		}

		.showcase .chevron {
			display: none;
		}

		.showcase[data-reveal] [data-r-lift] {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Three behaviours, all bound per instance so that two showcases on a page
	 * never reach into each other: the accordion, the heading reveal, and
	 * play-on-visible for any video.
	 */

	const reduceMotion = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	/* ---- accordion ---- */

	const setupAccordion = (root: HTMLElement) => {
		/* Each list is its own exclusive group. Scoping the query to the list
		   rather than the root is what lets one row stay open while another
		   row's list is untouched. */
		for (const list of root.querySelectorAll<HTMLElement>("[data-details]")) {
			const items = [...list.querySelectorAll<HTMLElement>("[data-detail]")];
			if (items.length === 0) continue;

			const setOpen = (item: HTMLElement, open: boolean) => {
				const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
				const panel = item.querySelector<HTMLElement>("[data-detail-panel]");
				if (!button || !panel) return;
				item.toggleAttribute("data-open", open);
				button.setAttribute("aria-expanded", open ? "true" : "false");
				/* A collapsed panel is still in the accessibility tree and still
				   focusable; `inert` takes it out of both without disturbing the
				   height transition. */
				panel.inert = !open;
			};

			for (const item of items) setOpen(item, item.hasAttribute("data-open"));

			for (const item of items) {
				const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
				if (!button) continue;

				button.addEventListener("click", () => {
					const willOpen = !item.hasAttribute("data-open");
					for (const other of items) setOpen(other, false);
					if (willOpen) setOpen(item, true);
				});
			}
		}
	};

	/* ---- heading reveal ---- */

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const targets = [...root.querySelectorAll<HTMLElement>("[data-r-lift]")];
		if (targets.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			targets.forEach((target) => target.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The bottom margin sets the trigger point. The large top margin is
			 * what makes a jump safe: an observer reports only a *change* in
			 * intersection, so a heading that goes from below the viewport to
			 * above it in one jump — an anchor link, a restored scroll position
			 * — would read 0 both times, never fire, and stay invisible for
			 * good. Extending the root upwards means "already scrolled past"
			 * still counts as intersecting.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		targets.forEach((target) => observer.observe(target));
	};

	/* ---- play videos only while they are on screen ---- */

	const setupVideos = (root: HTMLElement) => {
		const videos = [...root.querySelectorAll<HTMLVideoElement>("[data-video]")];
		if (videos.length === 0) return;

		/* An autoplaying loop is motion the reader did not ask for. Under a
		   reduced-motion preference the poster stands in for it. */
		if (reduceMotion() || !("IntersectionObserver" in window)) return;

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					const video = entry.target as HTMLVideoElement;
					if (entry.isIntersecting) {
						/* A rejected play() is normal — a background tab, or a
						   policy that wants a gesture first. The poster stays. */
						video.play().catch(() => {});
					} else {
						video.pause();
					}
				}
			},
			{ threshold: 0.25 },
		);

		videos.forEach((video) => observer.observe(video));
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-showcase]")) {
		setupAccordion(root);
		setupReveal(root);
		setupVideos(root);
	}
</script>