Component library
Back to catalog

Accordion success showcase

A navy section with a faint tyre-track texture: eyebrow, big white heading and an accordion of results on the left (the open one a raised card with an orange left edge), and on the right that item's port photo with a white info card over its lower-left corner.

accordionservicesresultsimagedarkappearlogistics

Open preview in a new tab

Use this component

Three ways to get Accordion success 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 2 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Accordion success showcase from my Astro Component Library to this Astro project.

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

Read it first
Install kit: "Accordion success showcase"
Library page: https://astro.baysixmedia.com/components/accordion-success-showcase/
Files: 2 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/AccordionSuccessShowcase.astro =====
---
/**
 * AccordionSuccessShowcase
 *
 * A dark navy section with a faint tyre-track texture. Left: an orange
 * eyebrow, a big white heading and an accordion list between two hairlines.
 * The open item is a raised translucent card with a curved orange left border
 * and an orange round arrow; closed items show their title, their line of
 * text and a dark round arrow. Right: the open item's photo with a white info
 * card overlapping its lower-left corner.
 *
 * A real accordion: each title is a <button aria-expanded aria-controls> for
 * that item's showcase panel (photo + info card). One open at a time, the
 * first open by default; clicking the open item keeps it open, so the photo
 * column is never empty. The button's hit area is stretched over the whole
 * item, so clicking anywhere on it works, as on the reference.
 *
 * Appear on scroll: the text side slides in from -40px, the photo side from
 * +40px. Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface SuccessCard {
	title: string;
	text?: string;
}

export interface SuccessItem {
	title: string;
	text?: string;
	/** This item's photo. Falls back to the section `image`. */
	image?: { src?: string; alt?: string };
	/** The white info card over the photo. `null` drops it. */
	card?: SuccessCard | null;
}

export interface AccordionSuccessShowcaseProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Level of each item title. Defaults to one below the heading. */
	itemHeadingLevel?: "h3" | "h4";
	items?: SuccessItem[];
	/** Shared photo for items without their own. `src: ""` = placeholder. */
	image?: { src?: string; alt?: string };
	/** 1-based index of the item open on load. */
	initialOpen?: number;
	/** The faint tyre-track texture. */
	texture?: boolean;
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends AccordionSuccessShowcaseProps {}

/* Items, results, heading and photo are the caller's: none are defaulted.
   No items → no section. */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	itemHeadingLevel,
	items = [],
	image: imageProp,
	initialOpen = 1,
	texture = true,
	background,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const uid = id ?? `ass-${Math.random().toString(36).slice(2, 8)}`;
const Heading = headingLevel;
const ItemHeading = itemHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");

/* No default photo: an item with no image of its own and no shared `image`
   gets the neutral placeholder. */
const shared = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };

const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
/* An item needs a title (it is the button); a card needs a title. */
const list = (items ?? []).filter((item) => item && filled(item.title));

const openIndex = Math.min(Math.max(1, Math.round(initialOpen)), Math.max(list.length, 1)) - 1;

const rows = list.map((item, i) => {
	const img = item.image ? { ...shared, ...item.image } : shared;
	if (item.image?.src !== undefined && item.image.alt === undefined) img.alt = "";
	return {
		...item,
		img,
		card: item.card && filled(item.card.title) ? item.card : null,
		open: i === openIndex,
		btnId: `${uid}-btn-${i}`,
		panelId: `${uid}-panel-${i}`,
	};
});

/* Measured photo: 601 × 752 at desktop (~0.8:1); the file is 1100 × 1146. */
const IMAGE_SPEC = "~4:5 · 1100 × 1146 px";
---

{rows.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{rows.length > 0 && (
<section
	class:list={["ass", className]}
	id={id}
	style={`--ass-rows: ${rows.length * 2 + 2};${background ? ` --ass-bg: ${background};` : ""}`}
	data-ass-root
>
	{
		texture && (
			<svg class="ass-texture" viewBox="0 0 700 760" preserveAspectRatio="xMinYMin slice" aria-hidden="true" focusable="false">
				<defs>
					<pattern id={`${uid}-tread`} width="64" height="30" patternUnits="userSpaceOnUse">
						<path d="M2 2 32 18 62 2v9L32 27 2 11z" fill="#fff" />
					</pattern>
				</defs>
				<g transform="rotate(24 350 380)">
					<rect x="90" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
					<rect x="300" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
				</g>
			</svg>
		)
	}
	<div class="ass-inner">
		{(eyebrow || heading) && (
		<div class="ass-head" data-ass-left>
			{
				eyebrow && (
					<p class="ass-eyebrow">
						{eyebrow}
						<span class="ass-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="ass-heading">{heading}</Heading>}
		</div>
		)}

		{
			rows.map((row, i) => (
				<>
					<div
						class:list={["ass-item", { "ass-item--first": i === 0, "ass-item--last": i === rows.length - 1 }]}
						data-ass-item
						data-ass-left
						data-open={row.open ? "" : undefined}
						style={`--ass-row: ${i * 2 + 2};`}
					>
						<ItemHeading class="ass-item-title">
							<button
								type="button"
								class="ass-btn"
								id={row.btnId}
								aria-expanded={row.open ? "true" : "false"}
								aria-controls={row.panelId}
								data-ass-btn
							>
								{row.title}
							</button>
						</ItemHeading>
						{row.text && <p class="ass-item-text">{row.text}</p>}
						<span class="ass-arrow" aria-hidden="true">
							<svg viewBox="0 0 10 10" width="10" height="10">
								<path d="M1.5 8.5 8.5 1.5M3 1.5h5.5V7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
							</svg>
						</span>
					</div>
					<div
						class="ass-panel"
						id={row.panelId}
						role="region"
						aria-labelledby={row.btnId}
						data-ass-panel
						data-ass-right
						data-open={row.open ? "" : undefined}
						style={`--ass-row: ${i * 2 + 3};`}
					>
						<div class="ass-photo">
							{row.img.src ? (
								<img
									src={row.img.src}
									alt={row.img.alt}
									width="1100"
									height="1146"
									loading="lazy"
									decoding="async"
								/>
							) : (
								<div class="ass-ph" role="img" aria-label="Image placeholder">
									<div class="ass-ph-text" aria-hidden="true">
										<span class="ass-ph-label">Image</span>
										<span class="ass-ph-spec">{IMAGE_SPEC}</span>
									</div>
								</div>
							)}
						</div>
						{row.card && (
							<div class="ass-card">
								<svg class="ass-card-icon" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
									<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
										<path d="M11 14V4m-3.5 3.5L11 4l3.5 3.5M20 14V4m-3.5 3.5L20 4l3.5 3.5M29 14V4m-3.5 3.5L29 4l3.5 3.5" />
										<path d="M5 19h27a4 4 0 0 1 0 8H8a4 4 0 0 0 0 8h27" />
									</g>
								</svg>
								<p class="ass-card-title">{row.card.title}</p>
								{row.card.text && <p class="ass-card-text">{row.card.text}</p>}
							</div>
						)}
					</div>
				</>
			))
		}
	</div>
</section>
)}

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

		position: relative;
		box-sizing: border-box;
		padding: 80px 0;
		background: var(--ass-bg, var(--ass-navy));
		color: #fff;
		font-family: var(--ass-body);
		overflow: clip;
		isolation: isolate;
	}

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

	/* Tyre tracks: two chevron-tread bands, very faint, fading out. */
	.ass-texture {
		position: absolute;
		top: 28%;
		left: 0;
		z-index: -1;
		width: min(48%, 700px);
		height: 72%;
		opacity: 0.045;
		pointer-events: none;
		-webkit-mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
		mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
	}

	.ass-inner {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.ass-head {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px;
		padding-bottom: 32px;
	}

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

	.ass-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.ass .ass-heading {
		margin: 0;
		font: 600 32px/1.2 var(--ass-display);
		color: #fff;
		overflow-wrap: break-word;
	}

	/* ---- accordion items ---- */
	.ass-item {
		position: relative;
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		row-gap: 16px;
		margin-top: 20px;
		padding: 16px 0 16px 18px;
		border-left: 3px solid rgb(255 255 255 / 0.15);
		border-radius: 20px;
		cursor: pointer;
		transition:
			background-color 0.4s var(--ass-ease),
			border-color 0.4s var(--ass-ease),
			padding 0.4s var(--ass-ease);
	}

	.ass-item--first {
		margin-top: 0;
	}

	.ass-item[data-open] {
		border-left-color: var(--ass-orange);
		background: rgb(255 255 255 / 0.07);
	}

	.ass .ass-item-title {
		margin: 0;
		font: 600 22px/34px var(--ass-display);
		color: #fff;
	}

	.ass-btn {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 5px 0;
		border: 0;
		background: none;
		color: inherit;
		font: inherit;
		text-align: left;
		cursor: pointer;
	}

	/* Stretch the button's hit area over the whole item. */
	.ass-btn::after {
		content: "";
		position: absolute;
		inset: 0;
		border-radius: 20px;
	}

	.ass-btn:focus-visible {
		outline: none;
	}

	.ass-btn:focus-visible::after {
		outline: 2px solid var(--ass-orange);
		outline-offset: 3px;
	}

	.ass .ass-item-text {
		margin: 0;
		padding-right: 16px;
		font: 400 16px/21.04px var(--ass-body);
		color: #ecf0f1;
	}

	.ass-arrow {
		display: none;
		place-items: center;
		width: 42px;
		height: 42px;
		border-radius: 50px;
		background: rgb(255 255 255 / 0.07);
		color: #fff;
		transition:
			background-color 0.4s var(--ass-ease),
			transform 0.4s var(--ass-ease);
	}

	.ass-item[data-open] .ass-arrow {
		background: var(--ass-orange);
	}

	.ass-item:hover .ass-arrow {
		transform: rotate(45deg);
	}

	/* ---- showcase panels ---- */
	.ass-panel {
		position: relative;
		display: none;
		margin-top: 20px;
	}

	.ass-panel[data-open] {
		display: block;
	}

	.ass-photo {
		position: relative;
		aspect-ratio: 16 / 10;
		border-radius: 12px;
		overflow: hidden;
		background: #0b1a36;
	}

	.ass-photo img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.ass-card {
		position: relative;
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		width: auto;
		max-width: 330px;
		margin: -48px 16px 0;
		padding: 24px;
		border-radius: 20px;
		background: #fff;
		color: var(--ass-navy);
	}

	.ass-card-icon {
		display: block;
		width: 40px;
		height: 40px;
		color: var(--ass-orange);
	}

	.ass .ass-card-title {
		margin: 0;
		font: 600 22px/34px var(--ass-display);
		color: var(--ass-navy);
	}

	.ass .ass-card-text {
		margin: 0;
		font: 400 16px/21.04px var(--ass-body);
		color: var(--ass-muted);
	}

	.ass-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #0d1c3a 0 12px, #12234438 12px 24px), #0b1a36;
		color: rgb(255 255 255 / 0.7);
	}

	.ass-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

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

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

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

	/* ---- appear on scroll ---- */
	[data-ass-left],
	[data-ass-right] {
		transition:
			opacity 0.9s var(--ass-ease),
			transform 0.9s var(--ass-ease);
	}

	.ass-item[data-ass-left] {
		transition:
			opacity 0.9s var(--ass-ease),
			transform 0.9s var(--ass-ease),
			background-color 0.4s var(--ass-ease),
			border-color 0.4s var(--ass-ease),
			padding 0.4s var(--ass-ease);
	}

	.ass[data-ass-ready]:not([data-in]) [data-ass-left] {
		opacity: 0;
		transform: translateX(-40px);
	}

	.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
		opacity: 0;
		transform: translateX(40px);
	}

	/* ---- tablet and up: text left, photo right ---- */
	@media (min-width: 810px) {
		.ass {
			padding: 100px 0;
		}

		.ass-inner {
			grid-template-columns: minmax(0, 584px) minmax(0, 601px);
			grid-template-rows: repeat(var(--ass-rows), auto) 1fr;
			justify-content: space-between;
			column-gap: 40px;
		}

		.ass-head {
			grid-column: 1;
			grid-row: 1;
			gap: 24px;
			padding-bottom: 60px;
			border-bottom: 1px solid var(--ass-muted);
			margin-bottom: 30px;
		}

		.ass-eyebrow-line {
			display: block;
		}

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

		.ass-item {
			grid-column: 1;
			grid-row: var(--ass-row);
			grid-template-columns: minmax(0, 1fr) auto;
			align-items: center;
			row-gap: 24px;
			min-height: 100px;
			margin-top: 30px;
			padding: 0;
			border-left: 0 solid transparent;
			background: transparent;
		}

		.ass-item--first {
			margin-top: 0;
		}

		.ass-item--last {
			padding-bottom: 0;
		}

		.ass-item--last::before {
			content: "";
			position: absolute;
			right: 0;
			bottom: -30px;
			left: 0;
			height: 1px;
			background: var(--ass-muted);
		}

		.ass-item[data-open] {
			padding: 30px;
			border-left-width: 3px;
			background: rgb(255 255 255 / 0.07);
		}

		.ass .ass-item-title,
		.ass .ass-item-text {
			grid-column: 1;
			padding-right: 16px;
		}

		.ass .ass-item-title {
			font-size: 24px;
		}

		.ass-btn {
			padding: 0;
			min-height: 34px;
		}

		.ass-arrow {
			display: grid;
			grid-column: 2;
			grid-row: 1 / span 2;
		}

		/* All panels share the photo column; the open one shows. */
		.ass-panel,
		.ass-panel[data-open] {
			display: block;
			grid-column: 2;
			grid-row: 1 / -1;
			margin-top: 0;
		}

		.ass-panel {
			visibility: hidden;
			opacity: 0;
			transition:
				opacity 0.5s var(--ass-ease),
				transform 0.9s var(--ass-ease),
				visibility 0s linear 0.5s;
		}

		.ass-panel[data-open] {
			visibility: visible;
			opacity: 1;
			transition:
				opacity 0.5s var(--ass-ease),
				transform 0.9s var(--ass-ease),
				visibility 0s;
		}

		.ass-photo {
			height: 100%;
			min-height: 560px;
			aspect-ratio: auto;
		}

		.ass-card {
			position: absolute;
			bottom: 40px;
			left: 40px;
			width: calc(100% - 80px);
			max-width: 330px;
			margin: 0;
			padding: 30px;
			gap: 30px;
		}

		.ass .ass-card-title {
			margin-bottom: -3px;
			font-size: 24px;
		}
	}

	@media (min-width: 1200px) {
		.ass {
			padding: 120px 0;
		}

		.ass .ass-eyebrow {
			font-size: 18px;
		}

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

		.ass-photo {
			min-height: 752px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.ass-item,
		.ass-arrow,
		[data-ass-left],
		[data-ass-right],
		.ass-item[data-ass-left] {
			transition: none;
		}

		.ass-item:hover .ass-arrow {
			transform: none;
		}

		.ass[data-ass-ready]:not([data-in]) [data-ass-left],
		.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
			opacity: 1;
			transform: none;
		}
	}

	@media (prefers-reduced-motion: reduce) and (min-width: 810px) {
		.ass-panel,
		.ass-panel[data-open] {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.ass-item[data-open] {
			border: 1px solid CanvasText;
			border-left: 3px solid Highlight;
		}

		.ass-btn:focus-visible::after {
			outline-color: Highlight;
		}
	}
</style>

<script>
	/*
	 * Accordion + appear-on-scroll. One controller per instance, scoped to
	 * its root, aborted on astro:before-swap. Clicking a closed item opens it
	 * and closes the others; clicking the open one leaves it open. Arrow
	 * Up/Down, Home and End move focus between item buttons.
	 */
	function initAccordionSuccessShowcase(root: HTMLElement) {
		if (root.dataset.assReady === "" || root.dataset.assBound === "") return;
		const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ass-btn]"));
		if (!buttons.length) return;

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

		const open = (index: number) => {
			buttons.forEach((btn, i) => {
				const isOpen = i === index;
				btn.setAttribute("aria-expanded", String(isOpen));
				const item = btn.closest<HTMLElement>("[data-ass-item]");
				const panel = root.querySelector<HTMLElement>(`#${CSS.escape(btn.getAttribute("aria-controls") ?? "")}`);
				for (const el of [item, panel]) {
					if (!el) continue;
					if (isOpen) el.dataset.open = "";
					else delete el.dataset.open;
				}
			});
		};

		buttons.forEach((btn, i) => {
			btn.addEventListener(
				"click",
				() => {
					if (btn.getAttribute("aria-expanded") !== "true") open(i);
				},
				{ signal },
			);
			btn.addEventListener(
				"keydown",
				(event) => {
					let next = -1;
					if (event.key === "ArrowDown") next = (i + 1) % buttons.length;
					else if (event.key === "ArrowUp") next = (i - 1 + buttons.length) % buttons.length;
					else if (event.key === "Home") next = 0;
					else if (event.key === "End") next = buttons.length - 1;
					if (next < 0) return;
					event.preventDefault();
					buttons[next].focus();
				},
				{ signal },
			);
		});

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
		} else if (root.dataset.in !== "") {
			root.dataset.assReady = "";
			const observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.dataset.in = "";
						observer.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer.disconnect());
		}

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

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

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

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

===== END src/components/AccordionSuccessShowcase.astro =====

===== FILE (supporting): src/components/AccordionSuccessShowcase.md =====
# Accordion success showcase

A dark navy section with a faint tyre-track texture. Left: an orange eyebrow,
a big white heading and an accordion of results between two hairlines. The
open item is a raised translucent card with a curved orange left edge and an
orange round arrow; closed items show their title, their line of text and a
dark round arrow. Right: the open item's photo with a white info card over
its lower-left corner.

Catalog page: `/components/accordion-success-showcase`

Part of the Northpass Logistics family (`--tg-*` tokens). Home page.

## Files required

**`AccordionSuccessShowcase.astro`**: one file. No imports, no packages.

`AccordionSuccessShowcase.md`: this file.

`public/accordion-success-showcase/cranes-dusk.webp`: the catalog demo's
photo, passed as `image` (not a default). Pass your own `image`; without one
each panel shows a neutral "Image" placeholder.

## Basic usage

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

<AccordionSuccessShowcase
  heading="Freight Programs That Paid for Themselves"
  items={[
    { title: "Ocean Freight", text: "Cut landed cost by 18%.", card: { title: "Vessel Schedules", text: "Weekly sailings on 40 lanes." } },
    { title: "Air Cargo", text: "Lead time down to four days.", image: { src: "/img/air.webp", alt: "Cargo plane loading" } },
  ]}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Orange label with a trailing line (line hidden on a phone). Drawn only when passed. |
| `heading` | `string?` | `""` | Drawn only when passed. With neither eyebrow nor heading the head block (and its hairline) is omitted. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `itemHeadingLevel` | `"h3" \| "h4"?` | one below | Level of the heading wrapping each item button. |
| `items` | `SuccessItem[]?` | `[]` | `{ title, text?, image?, card? }`. `card: { title, text? }` or `null`. An item without `title` is dropped, a card without `title` is not drawn. No items → the component renders nothing. |
| `image` | `{ src?, alt? }?` | none | Shared photo for items without their own. None (or `src: ""`) = neutral placeholder. |
| `initialOpen` | `number?` | `1` | 1-based item open on load. |
| `texture` | `boolean?` | `true` | The faint tyre tracks. |
| `background` | `string?` | navy | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip. |
| `class`, `id` | `string?` | | `id` also seeds the button and panel ids. |

## Behaviour

**Accordion.** Each item title is a `<button aria-expanded aria-controls>` in
a heading. It controls that item's showcase panel (`role="region"`, labelled
by the button). One item is open at a time; clicking a closed item opens it
and closes the rest; clicking the open item leaves it open, so the photo
column is never empty. The button's hit area is stretched over the whole
item (a positioned `::after`), so clicking anywhere on the item works, as on
the reference. Arrow Up / Down, Home and End move focus between items;
Enter / Space open.

**Open vs closed item (desktop).** Open: `rgba(255,255,255,.07)` card, 20px
radius, 30px padding, 3px orange left border (curving with the radius),
orange 42px arrow. Closed: no card, no padding, `rgba(255,255,255,.07)`
arrow. Background, border and padding transition over 0.4s. Hovering an item
turns its arrow 45deg.

**Panels.** From 810px every panel sits in the right column (601 × 752 at
desktop, 12px radius) and the open one cross-fades in (0.5s). The info card
is 330px wide, 40px in from the photo's left and bottom edges. On a phone the
open panel sits directly under its item (16:10 photo, card overlapping its
bottom edge) and closed panels are `display: none`.

**Appear on scroll.** The heading and items slide in from
`translateX(-40px)`, the photo side from `translateX(40px)`, 0.9s, once, when
15% is in view. Hidden only after the script sets `data-ass-ready`.

**Texture.** Two chevron-tread bands in an inline SVG pattern, rotated 24deg,
white at 4.5% opacity, faded out with a radial mask, behind the left half of
the section.

## Accessibility

- Real buttons with synced `aria-expanded`; the controlled regions are hidden
  from assistive tech (`visibility: hidden` / `display: none`) when closed.
- Visible focus ring around the whole item.
- Without JavaScript the initially open item and its panel show; the other
  items' text is still visible, but their photos cannot be opened.
- Reduced motion: no appear, no cross-fade, no arrow turn.

## Script

One `AbortController` per instance for the click and key listeners and the
appear observer, aborted on `astro:before-swap`. Initialises on load and on
`astro:page-load`, idempotent. Never scrolls or moves focus on load.

## Images and credits

| File | Size | Source |
| :--- | :--- | :--- |
| `cranes-dusk.webp` | 1100 × 1146 | Unsplash `yx20mpDyr2I`, Unsplash License (free commercial use, no attribution required) |

## Approximations

- **Only one photo** was supplied, so all three demo items share it and
  differ by their info card. Give each item its own `image` for the
  reference's per-item photos.
- **Panel transition.** The reference stacks the three image blocks 882px
  apart inside a clipped frame, which suggests a vertical slide; the switch
  itself was not captured (scripted clicks did not register). A cross-fade is
  used instead.
- **Descriptions stay visible on closed items**, as the desktop screenshot
  shows; what the accordion opens is the item's showcase panel.
- **Phone.** The reference phone layout shows every item with its photo
  (all "open", orange edge on each). Here the accordion still works on a
  phone: only the open item shows its photo, and closed items get a faint
  left edge. The info card, absent on the phone reference, is kept below the
  photo so its content is not lost.
- The tyre-track texture is redrawn, not traced; its opacity is estimated.

===== END src/components/AccordionSuccessShowcase.md =====

===== FILE (required): public/accordion-success-showcase/cranes-dusk.webp =====
[binary asset — download https://astro.baysixmedia.com/accordion-success-showcase/cranes-dusk.webp and save it to public/accordion-success-showcase/cranes-dusk.webp]

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Accordion success showcase” from my Astro Component Library:
https://astro.baysixmedia.com/components/accordion-success-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.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
2 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/AccordionSuccessShowcase.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/accordion-success-showcase/cranes-dusk.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * AccordionSuccessShowcase
 *
 * A dark navy section with a faint tyre-track texture. Left: an orange
 * eyebrow, a big white heading and an accordion list between two hairlines.
 * The open item is a raised translucent card with a curved orange left border
 * and an orange round arrow; closed items show their title, their line of
 * text and a dark round arrow. Right: the open item's photo with a white info
 * card overlapping its lower-left corner.
 *
 * A real accordion: each title is a <button aria-expanded aria-controls> for
 * that item's showcase panel (photo + info card). One open at a time, the
 * first open by default; clicking the open item keeps it open, so the photo
 * column is never empty. The button's hit area is stretched over the whole
 * item, so clicking anywhere on it works, as on the reference.
 *
 * Appear on scroll: the text side slides in from -40px, the photo side from
 * +40px. Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface SuccessCard {
	title: string;
	text?: string;
}

export interface SuccessItem {
	title: string;
	text?: string;
	/** This item's photo. Falls back to the section `image`. */
	image?: { src?: string; alt?: string };
	/** The white info card over the photo. `null` drops it. */
	card?: SuccessCard | null;
}

export interface AccordionSuccessShowcaseProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Level of each item title. Defaults to one below the heading. */
	itemHeadingLevel?: "h3" | "h4";
	items?: SuccessItem[];
	/** Shared photo for items without their own. `src: ""` = placeholder. */
	image?: { src?: string; alt?: string };
	/** 1-based index of the item open on load. */
	initialOpen?: number;
	/** The faint tyre-track texture. */
	texture?: boolean;
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends AccordionSuccessShowcaseProps {}

/* Items, results, heading and photo are the caller's: none are defaulted.
   No items → no section. */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	itemHeadingLevel,
	items = [],
	image: imageProp,
	initialOpen = 1,
	texture = true,
	background,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const uid = id ?? `ass-${Math.random().toString(36).slice(2, 8)}`;
const Heading = headingLevel;
const ItemHeading = itemHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");

/* No default photo: an item with no image of its own and no shared `image`
   gets the neutral placeholder. */
const shared = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };

const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
/* An item needs a title (it is the button); a card needs a title. */
const list = (items ?? []).filter((item) => item && filled(item.title));

const openIndex = Math.min(Math.max(1, Math.round(initialOpen)), Math.max(list.length, 1)) - 1;

const rows = list.map((item, i) => {
	const img = item.image ? { ...shared, ...item.image } : shared;
	if (item.image?.src !== undefined && item.image.alt === undefined) img.alt = "";
	return {
		...item,
		img,
		card: item.card && filled(item.card.title) ? item.card : null,
		open: i === openIndex,
		btnId: `${uid}-btn-${i}`,
		panelId: `${uid}-panel-${i}`,
	};
});

/* Measured photo: 601 × 752 at desktop (~0.8:1); the file is 1100 × 1146. */
const IMAGE_SPEC = "~4:5 · 1100 × 1146 px";
---

{rows.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{rows.length > 0 && (
<section
	class:list={["ass", className]}
	id={id}
	style={`--ass-rows: ${rows.length * 2 + 2};${background ? ` --ass-bg: ${background};` : ""}`}
	data-ass-root
>
	{
		texture && (
			<svg class="ass-texture" viewBox="0 0 700 760" preserveAspectRatio="xMinYMin slice" aria-hidden="true" focusable="false">
				<defs>
					<pattern id={`${uid}-tread`} width="64" height="30" patternUnits="userSpaceOnUse">
						<path d="M2 2 32 18 62 2v9L32 27 2 11z" fill="#fff" />
					</pattern>
				</defs>
				<g transform="rotate(24 350 380)">
					<rect x="90" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
					<rect x="300" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
				</g>
			</svg>
		)
	}
	<div class="ass-inner">
		{(eyebrow || heading) && (
		<div class="ass-head" data-ass-left>
			{
				eyebrow && (
					<p class="ass-eyebrow">
						{eyebrow}
						<span class="ass-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="ass-heading">{heading}</Heading>}
		</div>
		)}

		{
			rows.map((row, i) => (
				<>
					<div
						class:list={["ass-item", { "ass-item--first": i === 0, "ass-item--last": i === rows.length - 1 }]}
						data-ass-item
						data-ass-left
						data-open={row.open ? "" : undefined}
						style={`--ass-row: ${i * 2 + 2};`}
					>
						<ItemHeading class="ass-item-title">
							<button
								type="button"
								class="ass-btn"
								id={row.btnId}
								aria-expanded={row.open ? "true" : "false"}
								aria-controls={row.panelId}
								data-ass-btn
							>
								{row.title}
							</button>
						</ItemHeading>
						{row.text && <p class="ass-item-text">{row.text}</p>}
						<span class="ass-arrow" aria-hidden="true">
							<svg viewBox="0 0 10 10" width="10" height="10">
								<path d="M1.5 8.5 8.5 1.5M3 1.5h5.5V7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
							</svg>
						</span>
					</div>
					<div
						class="ass-panel"
						id={row.panelId}
						role="region"
						aria-labelledby={row.btnId}
						data-ass-panel
						data-ass-right
						data-open={row.open ? "" : undefined}
						style={`--ass-row: ${i * 2 + 3};`}
					>
						<div class="ass-photo">
							{row.img.src ? (
								<img
									src={row.img.src}
									alt={row.img.alt}
									width="1100"
									height="1146"
									loading="lazy"
									decoding="async"
								/>
							) : (
								<div class="ass-ph" role="img" aria-label="Image placeholder">
									<div class="ass-ph-text" aria-hidden="true">
										<span class="ass-ph-label">Image</span>
										<span class="ass-ph-spec">{IMAGE_SPEC}</span>
									</div>
								</div>
							)}
						</div>
						{row.card && (
							<div class="ass-card">
								<svg class="ass-card-icon" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
									<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
										<path d="M11 14V4m-3.5 3.5L11 4l3.5 3.5M20 14V4m-3.5 3.5L20 4l3.5 3.5M29 14V4m-3.5 3.5L29 4l3.5 3.5" />
										<path d="M5 19h27a4 4 0 0 1 0 8H8a4 4 0 0 0 0 8h27" />
									</g>
								</svg>
								<p class="ass-card-title">{row.card.title}</p>
								{row.card.text && <p class="ass-card-text">{row.card.text}</p>}
							</div>
						)}
					</div>
				</>
			))
		}
	</div>
</section>
)}

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

		position: relative;
		box-sizing: border-box;
		padding: 80px 0;
		background: var(--ass-bg, var(--ass-navy));
		color: #fff;
		font-family: var(--ass-body);
		overflow: clip;
		isolation: isolate;
	}

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

	/* Tyre tracks: two chevron-tread bands, very faint, fading out. */
	.ass-texture {
		position: absolute;
		top: 28%;
		left: 0;
		z-index: -1;
		width: min(48%, 700px);
		height: 72%;
		opacity: 0.045;
		pointer-events: none;
		-webkit-mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
		mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
	}

	.ass-inner {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.ass-head {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px;
		padding-bottom: 32px;
	}

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

	.ass-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.ass .ass-heading {
		margin: 0;
		font: 600 32px/1.2 var(--ass-display);
		color: #fff;
		overflow-wrap: break-word;
	}

	/* ---- accordion items ---- */
	.ass-item {
		position: relative;
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		row-gap: 16px;
		margin-top: 20px;
		padding: 16px 0 16px 18px;
		border-left: 3px solid rgb(255 255 255 / 0.15);
		border-radius: 20px;
		cursor: pointer;
		transition:
			background-color 0.4s var(--ass-ease),
			border-color 0.4s var(--ass-ease),
			padding 0.4s var(--ass-ease);
	}

	.ass-item--first {
		margin-top: 0;
	}

	.ass-item[data-open] {
		border-left-color: var(--ass-orange);
		background: rgb(255 255 255 / 0.07);
	}

	.ass .ass-item-title {
		margin: 0;
		font: 600 22px/34px var(--ass-display);
		color: #fff;
	}

	.ass-btn {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 5px 0;
		border: 0;
		background: none;
		color: inherit;
		font: inherit;
		text-align: left;
		cursor: pointer;
	}

	/* Stretch the button's hit area over the whole item. */
	.ass-btn::after {
		content: "";
		position: absolute;
		inset: 0;
		border-radius: 20px;
	}

	.ass-btn:focus-visible {
		outline: none;
	}

	.ass-btn:focus-visible::after {
		outline: 2px solid var(--ass-orange);
		outline-offset: 3px;
	}

	.ass .ass-item-text {
		margin: 0;
		padding-right: 16px;
		font: 400 16px/21.04px var(--ass-body);
		color: #ecf0f1;
	}

	.ass-arrow {
		display: none;
		place-items: center;
		width: 42px;
		height: 42px;
		border-radius: 50px;
		background: rgb(255 255 255 / 0.07);
		color: #fff;
		transition:
			background-color 0.4s var(--ass-ease),
			transform 0.4s var(--ass-ease);
	}

	.ass-item[data-open] .ass-arrow {
		background: var(--ass-orange);
	}

	.ass-item:hover .ass-arrow {
		transform: rotate(45deg);
	}

	/* ---- showcase panels ---- */
	.ass-panel {
		position: relative;
		display: none;
		margin-top: 20px;
	}

	.ass-panel[data-open] {
		display: block;
	}

	.ass-photo {
		position: relative;
		aspect-ratio: 16 / 10;
		border-radius: 12px;
		overflow: hidden;
		background: #0b1a36;
	}

	.ass-photo img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.ass-card {
		position: relative;
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		width: auto;
		max-width: 330px;
		margin: -48px 16px 0;
		padding: 24px;
		border-radius: 20px;
		background: #fff;
		color: var(--ass-navy);
	}

	.ass-card-icon {
		display: block;
		width: 40px;
		height: 40px;
		color: var(--ass-orange);
	}

	.ass .ass-card-title {
		margin: 0;
		font: 600 22px/34px var(--ass-display);
		color: var(--ass-navy);
	}

	.ass .ass-card-text {
		margin: 0;
		font: 400 16px/21.04px var(--ass-body);
		color: var(--ass-muted);
	}

	.ass-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #0d1c3a 0 12px, #12234438 12px 24px), #0b1a36;
		color: rgb(255 255 255 / 0.7);
	}

	.ass-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

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

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

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

	/* ---- appear on scroll ---- */
	[data-ass-left],
	[data-ass-right] {
		transition:
			opacity 0.9s var(--ass-ease),
			transform 0.9s var(--ass-ease);
	}

	.ass-item[data-ass-left] {
		transition:
			opacity 0.9s var(--ass-ease),
			transform 0.9s var(--ass-ease),
			background-color 0.4s var(--ass-ease),
			border-color 0.4s var(--ass-ease),
			padding 0.4s var(--ass-ease);
	}

	.ass[data-ass-ready]:not([data-in]) [data-ass-left] {
		opacity: 0;
		transform: translateX(-40px);
	}

	.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
		opacity: 0;
		transform: translateX(40px);
	}

	/* ---- tablet and up: text left, photo right ---- */
	@media (min-width: 810px) {
		.ass {
			padding: 100px 0;
		}

		.ass-inner {
			grid-template-columns: minmax(0, 584px) minmax(0, 601px);
			grid-template-rows: repeat(var(--ass-rows), auto) 1fr;
			justify-content: space-between;
			column-gap: 40px;
		}

		.ass-head {
			grid-column: 1;
			grid-row: 1;
			gap: 24px;
			padding-bottom: 60px;
			border-bottom: 1px solid var(--ass-muted);
			margin-bottom: 30px;
		}

		.ass-eyebrow-line {
			display: block;
		}

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

		.ass-item {
			grid-column: 1;
			grid-row: var(--ass-row);
			grid-template-columns: minmax(0, 1fr) auto;
			align-items: center;
			row-gap: 24px;
			min-height: 100px;
			margin-top: 30px;
			padding: 0;
			border-left: 0 solid transparent;
			background: transparent;
		}

		.ass-item--first {
			margin-top: 0;
		}

		.ass-item--last {
			padding-bottom: 0;
		}

		.ass-item--last::before {
			content: "";
			position: absolute;
			right: 0;
			bottom: -30px;
			left: 0;
			height: 1px;
			background: var(--ass-muted);
		}

		.ass-item[data-open] {
			padding: 30px;
			border-left-width: 3px;
			background: rgb(255 255 255 / 0.07);
		}

		.ass .ass-item-title,
		.ass .ass-item-text {
			grid-column: 1;
			padding-right: 16px;
		}

		.ass .ass-item-title {
			font-size: 24px;
		}

		.ass-btn {
			padding: 0;
			min-height: 34px;
		}

		.ass-arrow {
			display: grid;
			grid-column: 2;
			grid-row: 1 / span 2;
		}

		/* All panels share the photo column; the open one shows. */
		.ass-panel,
		.ass-panel[data-open] {
			display: block;
			grid-column: 2;
			grid-row: 1 / -1;
			margin-top: 0;
		}

		.ass-panel {
			visibility: hidden;
			opacity: 0;
			transition:
				opacity 0.5s var(--ass-ease),
				transform 0.9s var(--ass-ease),
				visibility 0s linear 0.5s;
		}

		.ass-panel[data-open] {
			visibility: visible;
			opacity: 1;
			transition:
				opacity 0.5s var(--ass-ease),
				transform 0.9s var(--ass-ease),
				visibility 0s;
		}

		.ass-photo {
			height: 100%;
			min-height: 560px;
			aspect-ratio: auto;
		}

		.ass-card {
			position: absolute;
			bottom: 40px;
			left: 40px;
			width: calc(100% - 80px);
			max-width: 330px;
			margin: 0;
			padding: 30px;
			gap: 30px;
		}

		.ass .ass-card-title {
			margin-bottom: -3px;
			font-size: 24px;
		}
	}

	@media (min-width: 1200px) {
		.ass {
			padding: 120px 0;
		}

		.ass .ass-eyebrow {
			font-size: 18px;
		}

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

		.ass-photo {
			min-height: 752px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.ass-item,
		.ass-arrow,
		[data-ass-left],
		[data-ass-right],
		.ass-item[data-ass-left] {
			transition: none;
		}

		.ass-item:hover .ass-arrow {
			transform: none;
		}

		.ass[data-ass-ready]:not([data-in]) [data-ass-left],
		.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
			opacity: 1;
			transform: none;
		}
	}

	@media (prefers-reduced-motion: reduce) and (min-width: 810px) {
		.ass-panel,
		.ass-panel[data-open] {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.ass-item[data-open] {
			border: 1px solid CanvasText;
			border-left: 3px solid Highlight;
		}

		.ass-btn:focus-visible::after {
			outline-color: Highlight;
		}
	}
</style>

<script>
	/*
	 * Accordion + appear-on-scroll. One controller per instance, scoped to
	 * its root, aborted on astro:before-swap. Clicking a closed item opens it
	 * and closes the others; clicking the open one leaves it open. Arrow
	 * Up/Down, Home and End move focus between item buttons.
	 */
	function initAccordionSuccessShowcase(root: HTMLElement) {
		if (root.dataset.assReady === "" || root.dataset.assBound === "") return;
		const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ass-btn]"));
		if (!buttons.length) return;

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

		const open = (index: number) => {
			buttons.forEach((btn, i) => {
				const isOpen = i === index;
				btn.setAttribute("aria-expanded", String(isOpen));
				const item = btn.closest<HTMLElement>("[data-ass-item]");
				const panel = root.querySelector<HTMLElement>(`#${CSS.escape(btn.getAttribute("aria-controls") ?? "")}`);
				for (const el of [item, panel]) {
					if (!el) continue;
					if (isOpen) el.dataset.open = "";
					else delete el.dataset.open;
				}
			});
		};

		buttons.forEach((btn, i) => {
			btn.addEventListener(
				"click",
				() => {
					if (btn.getAttribute("aria-expanded") !== "true") open(i);
				},
				{ signal },
			);
			btn.addEventListener(
				"keydown",
				(event) => {
					let next = -1;
					if (event.key === "ArrowDown") next = (i + 1) % buttons.length;
					else if (event.key === "ArrowUp") next = (i - 1 + buttons.length) % buttons.length;
					else if (event.key === "Home") next = 0;
					else if (event.key === "End") next = buttons.length - 1;
					if (next < 0) return;
					event.preventDefault();
					buttons[next].focus();
				},
				{ signal },
			);
		});

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
		} else if (root.dataset.in !== "") {
			root.dataset.assReady = "";
			const observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.dataset.in = "";
						observer.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer.disconnect());
		}

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

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

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

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