Component librarylocal catalog
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, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into 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 the branding and content open.

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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Service showcase
Library page: https://astro.baysixmedia.com/components/service-showcase

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Service showcase from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/service-showcase

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

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.

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 defaultItems: ShowcaseItem[] = [
	{
		title: "Interface",
		description:
			"Screens, states and the motion between them. We design the parts people touch and hand over a system the team can keep building on without asking permission.",
		placeholderLabel: "16 : 9",
		details: [
			{
				label: "Design systems",
				body: "Tokens, components and the written rules that keep them honest — delivered as code, not a slide deck about code.",
			},
			{
				label: "Prototypes",
				body: "Clickable builds at real fidelity, so the argument happens over something concrete instead of a static frame.",
			},
			{
				label: "Accessibility",
				body: "Focus order, contrast and reduced-motion behaviour designed in from the first screen rather than audited at the end.",
			},
			{
				label: "Handover",
				body: "Annotated source, a component catalog and a short recording of the reasoning, so the decisions outlive the project.",
			},
		],
		cta: { label: "See the work", href: "#interface" },
	},
	{
		title: "Motion",
		description:
			"Timing, easing and restraint. We treat animation as a way to explain what changed, not as decoration bolted on once the layout is finished.",
		placeholderLabel: "16 : 9",
		details: [
			{
				label: "Interaction",
				body: "The small stuff — what a control does under a pointer, under a finger, and for someone who has asked for less movement.",
			},
			{
				label: "Transitions",
				body: "Route and state changes that keep the reader oriented, with a clear origin and destination for everything that moves.",
			},
			{
				label: "Performance",
				body: "Compositor-friendly properties, measured on a mid-range phone rather than on the machine that authored it.",
			},
		],
		cta: { label: "See the work", href: "#motion" },
	},
];

const {
	heading = "What we do",
	headingLevel = "h2",
	items = defaultItems,
	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;
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\")";
---

<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">
			{
				items.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>