Component library
Back to catalog

Sticky image FAQ

An eyebrow, display heading and pill link over a two-column FAQ: a photo card pinned 50px from the top while rounded accordion rows scroll past, their navy plus turning over into a minus.

faqaccordiondetailsstickydisclosure

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/sticky-image-faq/

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: "Sticky image FAQ"
Library page: https://astro.baysixmedia.com/components/sticky-image-faq/
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/StickyImageFaq.astro =====
---
/**
 * Sticky image FAQ
 *
 * An eyebrow, a display heading and a pill link across the top; below, a tall
 * photo card (a labelled placeholder until you pass `photo.src`) that stays pinned while an accordion of questions scrolls past
 * it on the right.
 *
 * Built on `<details>` and `<summary>`, so every row opens, closes, takes
 * focus and announces its state before any script runs, and `name` gives the
 * one-at-a-time rule natively. The small controller adds only the height
 * animation, which the browser cannot do for a `<details>` on its own.
 */

export interface StickyFaqItem {
	question: string;
	/** Plain text. Blank lines split it into paragraphs. */
	answer: string;
}

export interface StickyFaqPhoto {
	src?: string;
	/** Empty marks it decorative, which it is by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder while `src` is unset. */
	hint?: string;
}

/* The slot's shape, from the reference: a 410×480 card, i.e. about 6:7. */
const PHOTO_SPEC = "~6:7 · 820 × 960 px";

interface Props {
	/** Pill above the heading. `""` hides it. */
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Pill link beside the heading. Needs both halves; `""` on either hides it. */
	ctaLabel?: string;
	ctaHref?: string;
	/** `"outline"` is the measured look; `"accent"` fills it orange. */
	ctaTone?: "outline" | "accent";
	items?: StickyFaqItem[];
	/** Which row starts open, counting from 1. `0` opens none. */
	initialOpen?: number;
	/** One row open at a time, through the `name` attribute on `<details>`. */
	exclusive?: boolean;
	/**
	 * The picture beside the list. With no `src` a labelled placeholder fills
	 * the card (the default); `false` drops the column and the list takes the width.
	 */
	photo?: StickyFaqPhoto | false;
	/** Pin the image while the list scrolls (two-column layout only). */
	stickyImage?: boolean;
	/** Google Fonts stylesheet for Bebas Neue + Poppins. `""` if the site loads them already. */
	fontStylesheet?: string;
	/** Root id. Also seeds the heading id and the exclusivity group name. */
	id?: string;
}

const {
	eyebrow = "FAQs",
	heading = "",
	headingLevel = "h2",
	ctaLabel = "",
	ctaHref = "",
	ctaTone = "outline",
	items = [],
	initialOpen = 1,
	exclusive = true,
	photo = {},
	stickyImage = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
/* No default subject: the placeholder names one only when `photo.hint` does. */
const photoHint = (photo && photo.hint) || "";

/*
 * One group name per instance. `name` is document-wide, so two of these on a
 * page would close each other's rows if they shared one.
 */
const uid = id ?? `sif-${Math.random().toString(36).slice(2, 9)}`;

const openIndex =
	Number.isInteger(initialOpen) && initialOpen >= 1 && initialOpen <= items.length ? initialOpen - 1 : -1;

/* Only schemes that navigate: this file is copied into other projects where
   the href may come from a CMS. */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeCta = ctaHref && SAFE_SCHEME.test(ctaHref.trim()) ? ctaHref.trim() : "";
const showCta = Boolean(ctaLabel && safeCta);

const paragraphs = (text: string) =>
	text
		.split(/\n\s*\n/)
		.map((p) => p.trim())
		.filter(Boolean);
---

{items.length > 0 && (
<section
	class="sif"
	id={id}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	data-sticky-faq
	data-sticky={stickyImage ? "" : undefined}
	data-has-image={photo ? "" : undefined}
>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="sif-container">
		{(eyebrow || heading || showCta) && (
			<div class="sif-top">
				{(eyebrow || heading) && (
					<div class="sif-title">
						{eyebrow && <p class="sif-eyebrow">{eyebrow}</p>}
						{heading && <Heading class="sif-heading" id={`${uid}-heading`}>{heading}</Heading>}
					</div>
				)}
				{showCta && (
					<a class="sif-cta" href={safeCta} data-tone={ctaTone}>
						<span>{ctaLabel}</span>
						<span class="sif-cta-icon" aria-hidden="true">
							<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
								<path
									d="M5 12h14M13 6l6 6-6 6"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
								/>
							</svg>
						</span>
					</a>
				)}
			</div>
		)}

		<div class="sif-content">
			{photo && (
				<div class="sif-media">
					{photo.src ? (
						<img class="sif-img" src={photo.src} alt={photo.alt ?? ""} loading="lazy" decoding="async" />
					) : (
						<div class="sif-img sif-ph" role="img" aria-label={photoHint ? `Image placeholder: ${photoHint}` : "Image placeholder"}>
							<span class="sif-ph-text" aria-hidden="true">
								<span class="sif-ph-label">Image</span>
								{photoHint && <span class="sif-ph-hint">{photoHint}</span>}
								<span class="sif-ph-spec">{PHOTO_SPEC}</span>
							</span>
						</div>
					)}
				</div>
			)}

			<div class="sif-list">
				{items.map((item, index) => (
					<details
						class="sif-row"
						data-sif-row
						name={exclusive ? uid : undefined}
						open={index === openIndex}
					>
						<summary class="sif-summary" data-sif-summary>
							<span class="sif-question">{item.question}</span>
							{/* Two bars on pseudo-elements: a plus that turns over into a
							    minus, so it inherits colour and costs no request. */}
							<span class="sif-icon" aria-hidden="true"></span>
						</summary>
						<div class="sif-panel">
							<div class="sif-panel-inner">
								<div class="sif-answer">
									{paragraphs(item.answer).map((p) => <p>{p}</p>)}
								</div>
							</div>
						</div>
					</details>
				))}
			</div>
		</div>
	</div>
</section>
)}

<script>
	/*
	 * The disclosure already works without this: <details> opens and shuts,
	 * and `name` keeps one row open at a time. What the browser cannot do is
	 * animate it — `open` flips and the content appears or vanishes in the
	 * same frame.
	 *
	 * So the controller takes over the click and uses one attribute,
	 * `data-sif-shut`, meaning "render as shut whatever `open` says". Every
	 * open style is written against `[open]:not([data-sif-shut])`, so with no
	 * script the guard never appears and `[open]` alone is the open state.
	 *
	 * - Closing: the guard goes on first, the panel animates to 0fr, and
	 *   `open` is cleared when the animation has run.
	 * - Opening: the guard goes on for one committed frame so a never-opened
	 *   panel has a 0fr frame to grow from; without it the row jumps.
	 */
	const OPEN_SEL = "[data-sif-row]";

	function initStickyFaq(root: HTMLElement) {
		if (root.dataset.sifReady === "") return;
		const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>(OPEN_SEL));
		if (rows.length === 0) return;

		root.dataset.sifReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
		const timers = new Map<HTMLDetailsElement, number>();
		const served = rows.map((row) => ({ row, name: row.name, open: row.open }));

		/*
		 * Exclusivity moves from the browser to here while the script runs: left
		 * on, the browser shuts the sibling instantly and its close animation
		 * never gets a frame.
		 */
		const exclusive = rows.some((row) => row.name !== "");
		for (const row of rows) row.name = "";

		const closeMs = () => {
			const raw = getComputedStyle(root).getPropertyValue("--sif-close").trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 300;
			return raw.endsWith("ms") ? value : value * 1000;
		};

		const clearTimer = (row: HTMLDetailsElement) => {
			const t = timers.get(row);
			if (t !== undefined) window.clearTimeout(t);
			timers.delete(row);
		};

		const showing = (row: HTMLDetailsElement) => row.open && row.dataset.sifShut === undefined;

		const openRow = (row: HTMLDetailsElement) => {
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.sifShut;
				row.open = true;
				return;
			}
			row.dataset.sifShut = "";
			row.open = true;
			void row.offsetHeight;
			delete row.dataset.sifShut;
		};

		const closeRow = (row: HTMLDetailsElement) => {
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.sifShut;
				row.open = false;
				return;
			}
			row.dataset.sifShut = "";
			timers.set(
				row,
				window.setTimeout(() => {
					timers.delete(row);
					delete row.dataset.sifShut;
					row.open = false;
				}, closeMs()),
			);
		};

		for (const row of rows) {
			const summary = row.querySelector<HTMLElement>("[data-sif-summary]");
			if (!summary) continue;
			/* Enter and Space on a <summary> dispatch click too, so this covers
			   pointer and keyboard alike. */
			summary.addEventListener(
				"click",
				(event) => {
					event.preventDefault();
					if (showing(row)) {
						closeRow(row);
						return;
					}
					if (exclusive) {
						for (const other of rows) if (other !== row && showing(other)) closeRow(other);
					}
					openRow(row);
				},
				{ signal },
			);
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				for (const row of rows) clearTimer(row);
				for (const { row, name, open } of served) {
					delete row.dataset.sifShut;
					row.name = name;
					row.open = open;
				}
				delete root.dataset.sifReady;
				controller.abort();
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-sticky-faq]").forEach(initStickyFaq);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.sif {
		--sif-navy: var(--lx-navy, #0a2540);
		--sif-accent: var(--lx-accent, #ff5f00);
		--sif-surface: var(--lx-surface, #f6f6f6);
		--sif-ink-soft: var(--lx-ink-soft, #555555);
		--sif-hairline: rgba(10, 37, 64, 0.14);
		--sif-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--sif-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		/* Not measurable from a still capture; chosen to feel like the reference. */
		--sif-open: 400ms;
		--sif-close: 300ms;
		--sif-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		padding: 60px 20px;
		background: var(--sif-surface);
		font-family: var(--sif-body);
		color: var(--sif-navy);
	}

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

	.sif-container {
		container-type: inline-size;
		max-width: 1200px;
		margin-inline: auto;
	}

	/* ---- top row ------------------------------------------------------ */

	.sif-top {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		margin-bottom: 40px;
	}

	.sif-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px;
		max-width: 462px;
	}

	.sif-eyebrow {
		margin: 0;
		padding: 7px 15px;
		border: 1px solid var(--sif-hairline);
		border-radius: 100px;
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--sif-navy);
	}

	.sif-heading {
		margin: 0;
		font-family: var(--sif-display);
		font-weight: 400;
		font-size: clamp(30px, 4cqi, 48px);
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--sif-navy);
	}

	.sif-cta {
		flex: 0 0 auto;
		display: inline-flex;
		align-items: center;
		gap: 24px;
		min-height: 50px;
		padding: 4px 4px 4px 18px;
		border: 1px solid var(--sif-hairline);
		border-radius: 100px;
		background: var(--sif-surface);
		font-size: 14px;
		font-weight: 600;
		line-height: 14px;
		color: var(--sif-navy);
		text-decoration: none;
		transition:
			background-color var(--sif-close) var(--sif-ease),
			border-color var(--sif-close) var(--sif-ease);
	}

	.sif-cta-icon {
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		background: var(--sif-navy);
		color: #ffffff;
	}

	.sif-cta-icon svg {
		transition: translate var(--sif-close) var(--sif-ease);
	}

	.sif-cta:hover {
		border-color: var(--sif-navy);
	}

	.sif-cta:hover .sif-cta-icon svg {
		translate: 2px 0;
	}

	.sif-cta[data-tone="accent"] {
		border-color: transparent;
		background: var(--sif-accent);
		color: #ffffff;
	}

	.sif-cta[data-tone="accent"] .sif-cta-icon {
		background: #ffffff;
		color: var(--sif-accent);
	}

	.sif-cta[data-tone="accent"]:hover {
		background: color-mix(in srgb, var(--sif-accent) 88%, #000000);
	}

	.sif-cta:focus-visible {
		outline: 2px solid var(--sif-accent);
		outline-offset: 3px;
	}

	/* ---- content ------------------------------------------------------ */

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

	.sif-media {
		position: relative;
		/* Without this a flex item's automatic minimum size is the picture's
		   own height, and the aspect-ratio below never wins. */
		min-height: 0;
		border-radius: 10px;
		overflow: clip;
		/* The measured card is 410×480; stacked on a phone it keeps that shape. */
		aspect-ratio: 410 / 480;
	}

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

	.sif-ph {
		display: grid;
		place-items: center;
		padding: 16px;
		border: 1px dashed rgba(10, 37, 64, 0.22);
		border-radius: 10px;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		text-align: center;
		color: rgba(10, 37, 64, 0.62);
	}

	.sif-ph-label,
	.sif-ph-hint,
	.sif-ph-spec {
		display: block;
	}

	.sif-ph-label {
		font-size: 12px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.sif-ph-hint {
		max-width: 28ch;
		margin: 8px auto;
		font-size: 14px;
		font-weight: 500;
		line-height: 1.4;
	}

	.sif-ph-spec {
		font-size: 12px;
		line-height: 1.2;
	}

	.sif-list {
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding: 10px;
		border-radius: 10px;
		background: #ffffff;
	}

	.sif-row {
		border-radius: 10px;
		background: var(--sif-surface);
	}

	.sif-summary {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 24px;
		min-height: 60px;
		padding: 10px 10px 10px 18px;
		border-radius: 10px;
		cursor: pointer;
		list-style: none;
	}

	.sif-summary::-webkit-details-marker {
		display: none;
	}

	.sif-summary:focus-visible {
		outline: 2px solid var(--sif-accent);
		outline-offset: 2px;
	}

	.sif-question {
		flex: 1 1 auto;
		min-width: 0;
		font-family: var(--sif-display);
		font-weight: 400;
		/* 16px on a phone, 24px at 1200 — both measured. */
		font-size: clamp(16px, 2cqi, 24px);
		line-height: 1.05;
		letter-spacing: -0.02em;
		color: var(--sif-navy);
	}

	.sif-icon {
		position: relative;
		flex: 0 0 auto;
		width: 40px;
		height: 40px;
		border-radius: 6px;
		background: var(--sif-navy);
		transition: rotate var(--sif-close) var(--sif-ease);
	}

	.sif-icon::before,
	.sif-icon::after {
		content: "";
		position: absolute;
		top: 50%;
		left: 50%;
		width: 14px;
		height: 2px;
		margin: -1px 0 0 -7px;
		border-radius: 1px;
		background: var(--sif-surface);
	}

	.sif-icon::after {
		rotate: 90deg;
		transition: scale var(--sif-close) var(--sif-ease);
	}

	.sif-summary:hover .sif-icon {
		background: color-mix(in srgb, var(--sif-navy) 85%, var(--sif-accent));
	}

	/* Open: the whole mark turns over (the reference's scale(-1,-1)) and the
	   upright bar folds away, leaving a minus. */
	.sif-row[open]:not([data-sif-shut]) .sif-icon {
		rotate: 180deg;
		transition-duration: var(--sif-open);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-icon::after {
		scale: 1 0;
		transition-duration: var(--sif-open);
	}

	.sif-panel {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows var(--sif-close) var(--sif-ease);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-panel {
		grid-template-rows: 1fr;
		transition-duration: var(--sif-open);
	}

	.sif-panel-inner {
		overflow: hidden;
		min-height: 0;
	}

	.sif-answer {
		margin: 4px 10px 10px;
		padding: 20px;
		border-radius: 10px;
		background: #ffffff;
		font-size: 16px;
		line-height: 1.6;
		color: var(--sif-ink-soft);
		opacity: 0;
		transition: opacity var(--sif-close) var(--sif-ease);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-answer {
		opacity: 1;
		transition: opacity calc(var(--sif-open) * 0.6) var(--sif-ease) calc(var(--sif-open) * 0.3);
	}

	.sif-answer p {
		margin: 0;
	}

	.sif-answer p + p {
		margin-top: 0.8em;
	}

	/* ---- wider containers --------------------------------------------- */

	@container (min-width: 560px) {
		.sif-media {
			aspect-ratio: 16 / 10;
		}
	}

	@container (min-width: 720px) {
		.sif-top {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-end;
			margin-bottom: 70px;
		}

		.sif-summary {
			gap: 40px;
		}
	}

	/* The measured desktop split: a 410px card (460 with its 50px gutter) and a
	   590px list, pushed to opposite edges. */
	@container (min-width: 900px) {
		.sif-content {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-start;
			gap: 40px;
		}

		.sif-media {
			flex: 0 1 410px;
			aspect-ratio: 410 / 480;
		}

		.sif[data-sticky] .sif-media {
			position: sticky;
			top: 50px;
		}

		.sif-list {
			flex: 0 1 590px;
		}

		.sif:not([data-has-image]) .sif-list {
			flex-basis: 100%;
		}
	}

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

	@media (prefers-reduced-motion: reduce) {
		.sif {
			--sif-open: 0.01ms;
			--sif-close: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		.sif-row,
		.sif-cta,
		.sif-answer {
			border: 1px solid CanvasText;
		}

		.sif-icon {
			border: 1px solid CanvasText;
		}

		.sif-icon::before,
		.sif-icon::after {
			background: CanvasText;
		}
	}
</style>

===== END src/components/StickyImageFaq.astro =====

===== FILE (supporting): src/components/StickyImageFaq.md =====
# Sticky image FAQ

An eyebrow, a display heading and a pill link across the top; below, a tall
photo card pinned 50px from the top of the viewport while an accordion of
questions scrolls past it on the right.

Catalog page: `/components/sticky-image-faq`

## Purpose

The FAQ section of a services page, when there is a photo worth keeping in
view. The rows are rounded grey cards inside one white card; the open row
shows its answer on a white inset, and a navy square turns its plus over into
a minus.

## Files required

**`StickyImageFaq.astro`** — one file: markup, a small script, scoped styles.

- No imports, no npm packages, no framework.
- Fonts: renders a `<link>` to Google Fonts (Bebas Neue + Poppins) unless you
  pass `fontStylesheet=""`.
- **No image files.** The photo slot shows a labelled placeholder until you
  pass `photo.src`.

`StickyImageFaq.md` — this file.

## Basic usage

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

<StickyImageFaq
  id="faq"
  heading="Questions we hear most"
  ctaLabel="Get a quote"
  ctaHref="/book"
  photo={{ src: "/img/loading-van.jpg", alt: "" }}
  items={[
    { question: "How fast will I get a quote?", answer: "Within two business hours." },
  ]}
/>
```

A page section: it paints `#f6f6f6` and applies its own padding (120/30
above 810px viewport width, 60/20 below).

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"FAQs"` | Pill above the heading. `""` hides it. |
| `heading` | `string?` | `""` | Also names the section (`aria-labelledby`). Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `ctaLabel` / `ctaHref` | `string?` | `""` / `""` | The pill link. Needs both; either `""` hides it. Only `http(s):`, `mailto:`, `tel:` and relative hrefs render. |
| `ctaTone` | `"outline" \| "accent"?` | `"outline"` | Outline is the measured look; accent fills it orange. |
| `items` | `{ question, answer }[]?` | `[]` | Plain text; a blank line in `answer` starts a new paragraph. No items → the component renders nothing. |
| `initialOpen` | `number?` | `1` | Which row starts open, from 1. `0` opens none. |
| `exclusive` | `boolean?` | `true` | One row at a time, via `name` on `<details>`. |
| `photo` | `{ src?, alt?, hint? } \| false` | `{}` | No `src` → placeholder (with `hint` printed on it when given). `false` drops the column; the list takes the width. |
| `stickyImage` | `boolean?` | `true` | Pin the photo in the two-column layout. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` if the site loads the fonts. |
| `id` | `string?` | — | Root id. Also seeds the heading id and the exclusivity group name. |

## Images

| Slot | Default hint | Ratio · size |
| :--- | :--- | :--- |
| `photo` (sticky card) | none (the catalog demo passes "Two warehouse workers in hi-vis vests loading cardboard boxes into the back of a white van, daylight") | ~6:7 · 820 × 960 px |

## Content defaults

The component invents no business copy. Heading, pill link, questions and
the placeholder hint default to empty; the catalog demo's freight FAQ
(quote times, tracking, cargo cover, border crossings) lives in the
registry's demo props. With no items it renders nothing (only its script
tag). The eyebrow `"FAQs"` stays as generic label text, but on its own it
does not make the section render. With no eyebrow, heading or pill, the top
row is omitted.

`object-fit: cover`. The card is 410×480 on desktop and on phones; on a
stacked tablet layout (container 560–899px) it becomes 16:10, so keep the
subject central.

## Behaviour

Built on `<details>`/`<summary>`. Without script every row opens and closes
natively and `name` keeps one open at a time.

The script adds only the height animation (`grid-template-rows: 0fr → 1fr`,
400ms open / 300ms close, answer fading in after a third of the open). The
technique and its one-frame guard are the same as `FaqAccordion`'s — see its
README for why: a `<details>` gives CSS nothing to animate from, and the
browser's own `name` exclusivity would shut the sibling in the same frame. So
while the script runs it takes the click, moves exclusivity off `name`, and
renders rows through a `data-sif-shut` guard; every open style is written
against `[open]:not([data-sif-shut])`.

| Input | What happens |
| :--- | :--- |
| Click / <kbd>Enter</kbd> / <kbd>Space</kbd> on a question | Toggles it; with `exclusive`, the open sibling closes (animated). |
| Click a row that is mid-close | Brings it back. |
| <kbd>Tab</kbd> | Pill link, then each question in order. Answer links (none by default) only when open. |

Lifecycle: boots on load and on every `astro:page-load`, idempotent per root
(`data-sif-ready`). One `AbortController` per instance owns every listener; on
`astro:before-swap` it clears timers, restores the served `name`/`open` state
and aborts. No focus moves, no scrolling, nothing on `document.body`.

## Accessibility

- Native disclosure semantics: `<summary>` is the button and exposes
  expanded/collapsed itself. No ARIA added.
- The plus/minus is `aria-hidden`; the question text is the name.
- `:focus-visible` is a 2px accent outline on the summary and the pill.
- Rows are 60px tall, the pill 50px.
- Forced colours: rows, answers, the icon square and the pill get borders;
  the icon bars take `CanvasText`.
- The photo is decorative by default; the placeholder is `role="img"` with
  `aria-label="Image placeholder: <hint>"` (just "Image placeholder" with no
  hint).

## Reduced motion

Both duration tokens collapse to `0.01ms` and the script skips the guard
frame, so rows open and shut instantly. Every state rests at a visible value.

## Without JavaScript

Rows open and close, `name` exclusivity works, the first row is open. Only
the height animation is missing.

## Responsive

Container queries on the 1200px container:

| Container width | Layout |
| :--- | :--- |
| < 560 | Stacked: heading, pill, photo (410:480), list. |
| 560–719 | Photo goes 16:10. |
| 720–899 | Heading and pill side by side, bottom-aligned. |
| ≥ 900 | Photo (flex 0 1 410px) left, sticky at `top: 50px`; list (flex 0 1 590px) right, pushed apart. |

Heading `clamp(30px, 4cqi, 48px)`, questions `clamp(16px, 2cqi, 24px)` — both
hit the measured phone and desktop sizes.

The sticky pin needs no ancestor with `overflow: hidden|auto` between the
section and the scrolling element — check your page wrapper if it does not
stick.

## Approximations

- **Question font is Bebas Neue 24px** as measured (the brief said Poppins
  600); **the CTA is the measured outline pill** with a navy arrow disc (the
  brief said orange — available as `ctaTone="accent"`).
- **The icon** is drawn as two bars: open, the whole square rotates 180°
  (the reference's `scale(-1,-1)`) and the upright bar folds to a minus, which
  matches the screenshot's minus.
- **Animation timing** is not measurable from a still capture: 400ms / 300ms
  on `cubic-bezier(.4,0,.2,1)` is a choice.
- **Eyebrow border** is a 1px hairline; the dump did not capture it.
- **Tablet** was not captured; the 16:10 photo and the 720px header switch
  are interpolated.
- Not verified in a real browser session with Playwright, keyboard or
  forced colours. Headless checks only: no horizontal overflow at
  320/390/834/1024/1280/1440, and a scripted run confirmed exclusive open,
  multi-open on the second instance, animated close, and restore on
  `astro:before-swap` / re-boot on `astro:page-load`.

===== END src/components/StickyImageFaq.md =====

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

--- NOT PROVEN ---
- ClientRouter: 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
“Sticky image FAQ” from my Astro Component Library:
https://astro.baysixmedia.com/components/sticky-image-faq/

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
1 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/StickyImageFaq.astro

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

---
/**
 * Sticky image FAQ
 *
 * An eyebrow, a display heading and a pill link across the top; below, a tall
 * photo card (a labelled placeholder until you pass `photo.src`) that stays pinned while an accordion of questions scrolls past
 * it on the right.
 *
 * Built on `<details>` and `<summary>`, so every row opens, closes, takes
 * focus and announces its state before any script runs, and `name` gives the
 * one-at-a-time rule natively. The small controller adds only the height
 * animation, which the browser cannot do for a `<details>` on its own.
 */

export interface StickyFaqItem {
	question: string;
	/** Plain text. Blank lines split it into paragraphs. */
	answer: string;
}

export interface StickyFaqPhoto {
	src?: string;
	/** Empty marks it decorative, which it is by default. */
	alt?: string;
	/** What the photo should show. Printed on the placeholder while `src` is unset. */
	hint?: string;
}

/* The slot's shape, from the reference: a 410×480 card, i.e. about 6:7. */
const PHOTO_SPEC = "~6:7 · 820 × 960 px";

interface Props {
	/** Pill above the heading. `""` hides it. */
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Pill link beside the heading. Needs both halves; `""` on either hides it. */
	ctaLabel?: string;
	ctaHref?: string;
	/** `"outline"` is the measured look; `"accent"` fills it orange. */
	ctaTone?: "outline" | "accent";
	items?: StickyFaqItem[];
	/** Which row starts open, counting from 1. `0` opens none. */
	initialOpen?: number;
	/** One row open at a time, through the `name` attribute on `<details>`. */
	exclusive?: boolean;
	/**
	 * The picture beside the list. With no `src` a labelled placeholder fills
	 * the card (the default); `false` drops the column and the list takes the width.
	 */
	photo?: StickyFaqPhoto | false;
	/** Pin the image while the list scrolls (two-column layout only). */
	stickyImage?: boolean;
	/** Google Fonts stylesheet for Bebas Neue + Poppins. `""` if the site loads them already. */
	fontStylesheet?: string;
	/** Root id. Also seeds the heading id and the exclusivity group name. */
	id?: string;
}

const {
	eyebrow = "FAQs",
	heading = "",
	headingLevel = "h2",
	ctaLabel = "",
	ctaHref = "",
	ctaTone = "outline",
	items = [],
	initialOpen = 1,
	exclusive = true,
	photo = {},
	stickyImage = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
/* No default subject: the placeholder names one only when `photo.hint` does. */
const photoHint = (photo && photo.hint) || "";

/*
 * One group name per instance. `name` is document-wide, so two of these on a
 * page would close each other's rows if they shared one.
 */
const uid = id ?? `sif-${Math.random().toString(36).slice(2, 9)}`;

const openIndex =
	Number.isInteger(initialOpen) && initialOpen >= 1 && initialOpen <= items.length ? initialOpen - 1 : -1;

/* Only schemes that navigate: this file is copied into other projects where
   the href may come from a CMS. */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeCta = ctaHref && SAFE_SCHEME.test(ctaHref.trim()) ? ctaHref.trim() : "";
const showCta = Boolean(ctaLabel && safeCta);

const paragraphs = (text: string) =>
	text
		.split(/\n\s*\n/)
		.map((p) => p.trim())
		.filter(Boolean);
---

{items.length > 0 && (
<section
	class="sif"
	id={id}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	data-sticky-faq
	data-sticky={stickyImage ? "" : undefined}
	data-has-image={photo ? "" : undefined}
>
	{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
	<div class="sif-container">
		{(eyebrow || heading || showCta) && (
			<div class="sif-top">
				{(eyebrow || heading) && (
					<div class="sif-title">
						{eyebrow && <p class="sif-eyebrow">{eyebrow}</p>}
						{heading && <Heading class="sif-heading" id={`${uid}-heading`}>{heading}</Heading>}
					</div>
				)}
				{showCta && (
					<a class="sif-cta" href={safeCta} data-tone={ctaTone}>
						<span>{ctaLabel}</span>
						<span class="sif-cta-icon" aria-hidden="true">
							<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
								<path
									d="M5 12h14M13 6l6 6-6 6"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
								/>
							</svg>
						</span>
					</a>
				)}
			</div>
		)}

		<div class="sif-content">
			{photo && (
				<div class="sif-media">
					{photo.src ? (
						<img class="sif-img" src={photo.src} alt={photo.alt ?? ""} loading="lazy" decoding="async" />
					) : (
						<div class="sif-img sif-ph" role="img" aria-label={photoHint ? `Image placeholder: ${photoHint}` : "Image placeholder"}>
							<span class="sif-ph-text" aria-hidden="true">
								<span class="sif-ph-label">Image</span>
								{photoHint && <span class="sif-ph-hint">{photoHint}</span>}
								<span class="sif-ph-spec">{PHOTO_SPEC}</span>
							</span>
						</div>
					)}
				</div>
			)}

			<div class="sif-list">
				{items.map((item, index) => (
					<details
						class="sif-row"
						data-sif-row
						name={exclusive ? uid : undefined}
						open={index === openIndex}
					>
						<summary class="sif-summary" data-sif-summary>
							<span class="sif-question">{item.question}</span>
							{/* Two bars on pseudo-elements: a plus that turns over into a
							    minus, so it inherits colour and costs no request. */}
							<span class="sif-icon" aria-hidden="true"></span>
						</summary>
						<div class="sif-panel">
							<div class="sif-panel-inner">
								<div class="sif-answer">
									{paragraphs(item.answer).map((p) => <p>{p}</p>)}
								</div>
							</div>
						</div>
					</details>
				))}
			</div>
		</div>
	</div>
</section>
)}

<script>
	/*
	 * The disclosure already works without this: <details> opens and shuts,
	 * and `name` keeps one row open at a time. What the browser cannot do is
	 * animate it — `open` flips and the content appears or vanishes in the
	 * same frame.
	 *
	 * So the controller takes over the click and uses one attribute,
	 * `data-sif-shut`, meaning "render as shut whatever `open` says". Every
	 * open style is written against `[open]:not([data-sif-shut])`, so with no
	 * script the guard never appears and `[open]` alone is the open state.
	 *
	 * - Closing: the guard goes on first, the panel animates to 0fr, and
	 *   `open` is cleared when the animation has run.
	 * - Opening: the guard goes on for one committed frame so a never-opened
	 *   panel has a 0fr frame to grow from; without it the row jumps.
	 */
	const OPEN_SEL = "[data-sif-row]";

	function initStickyFaq(root: HTMLElement) {
		if (root.dataset.sifReady === "") return;
		const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>(OPEN_SEL));
		if (rows.length === 0) return;

		root.dataset.sifReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
		const timers = new Map<HTMLDetailsElement, number>();
		const served = rows.map((row) => ({ row, name: row.name, open: row.open }));

		/*
		 * Exclusivity moves from the browser to here while the script runs: left
		 * on, the browser shuts the sibling instantly and its close animation
		 * never gets a frame.
		 */
		const exclusive = rows.some((row) => row.name !== "");
		for (const row of rows) row.name = "";

		const closeMs = () => {
			const raw = getComputedStyle(root).getPropertyValue("--sif-close").trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 300;
			return raw.endsWith("ms") ? value : value * 1000;
		};

		const clearTimer = (row: HTMLDetailsElement) => {
			const t = timers.get(row);
			if (t !== undefined) window.clearTimeout(t);
			timers.delete(row);
		};

		const showing = (row: HTMLDetailsElement) => row.open && row.dataset.sifShut === undefined;

		const openRow = (row: HTMLDetailsElement) => {
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.sifShut;
				row.open = true;
				return;
			}
			row.dataset.sifShut = "";
			row.open = true;
			void row.offsetHeight;
			delete row.dataset.sifShut;
		};

		const closeRow = (row: HTMLDetailsElement) => {
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.sifShut;
				row.open = false;
				return;
			}
			row.dataset.sifShut = "";
			timers.set(
				row,
				window.setTimeout(() => {
					timers.delete(row);
					delete row.dataset.sifShut;
					row.open = false;
				}, closeMs()),
			);
		};

		for (const row of rows) {
			const summary = row.querySelector<HTMLElement>("[data-sif-summary]");
			if (!summary) continue;
			/* Enter and Space on a <summary> dispatch click too, so this covers
			   pointer and keyboard alike. */
			summary.addEventListener(
				"click",
				(event) => {
					event.preventDefault();
					if (showing(row)) {
						closeRow(row);
						return;
					}
					if (exclusive) {
						for (const other of rows) if (other !== row && showing(other)) closeRow(other);
					}
					openRow(row);
				},
				{ signal },
			);
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				for (const row of rows) clearTimer(row);
				for (const { row, name, open } of served) {
					delete row.dataset.sifShut;
					row.name = name;
					row.open = open;
				}
				delete root.dataset.sifReady;
				controller.abort();
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-sticky-faq]").forEach(initStickyFaq);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.sif {
		--sif-navy: var(--lx-navy, #0a2540);
		--sif-accent: var(--lx-accent, #ff5f00);
		--sif-surface: var(--lx-surface, #f6f6f6);
		--sif-ink-soft: var(--lx-ink-soft, #555555);
		--sif-hairline: rgba(10, 37, 64, 0.14);
		--sif-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--sif-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		/* Not measurable from a still capture; chosen to feel like the reference. */
		--sif-open: 400ms;
		--sif-close: 300ms;
		--sif-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		padding: 60px 20px;
		background: var(--sif-surface);
		font-family: var(--sif-body);
		color: var(--sif-navy);
	}

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

	.sif-container {
		container-type: inline-size;
		max-width: 1200px;
		margin-inline: auto;
	}

	/* ---- top row ------------------------------------------------------ */

	.sif-top {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		margin-bottom: 40px;
	}

	.sif-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px;
		max-width: 462px;
	}

	.sif-eyebrow {
		margin: 0;
		padding: 7px 15px;
		border: 1px solid var(--sif-hairline);
		border-radius: 100px;
		font-size: 14px;
		font-weight: 500;
		line-height: 14px;
		text-transform: uppercase;
		color: var(--sif-navy);
	}

	.sif-heading {
		margin: 0;
		font-family: var(--sif-display);
		font-weight: 400;
		font-size: clamp(30px, 4cqi, 48px);
		line-height: 1;
		letter-spacing: -0.02em;
		color: var(--sif-navy);
	}

	.sif-cta {
		flex: 0 0 auto;
		display: inline-flex;
		align-items: center;
		gap: 24px;
		min-height: 50px;
		padding: 4px 4px 4px 18px;
		border: 1px solid var(--sif-hairline);
		border-radius: 100px;
		background: var(--sif-surface);
		font-size: 14px;
		font-weight: 600;
		line-height: 14px;
		color: var(--sif-navy);
		text-decoration: none;
		transition:
			background-color var(--sif-close) var(--sif-ease),
			border-color var(--sif-close) var(--sif-ease);
	}

	.sif-cta-icon {
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		background: var(--sif-navy);
		color: #ffffff;
	}

	.sif-cta-icon svg {
		transition: translate var(--sif-close) var(--sif-ease);
	}

	.sif-cta:hover {
		border-color: var(--sif-navy);
	}

	.sif-cta:hover .sif-cta-icon svg {
		translate: 2px 0;
	}

	.sif-cta[data-tone="accent"] {
		border-color: transparent;
		background: var(--sif-accent);
		color: #ffffff;
	}

	.sif-cta[data-tone="accent"] .sif-cta-icon {
		background: #ffffff;
		color: var(--sif-accent);
	}

	.sif-cta[data-tone="accent"]:hover {
		background: color-mix(in srgb, var(--sif-accent) 88%, #000000);
	}

	.sif-cta:focus-visible {
		outline: 2px solid var(--sif-accent);
		outline-offset: 3px;
	}

	/* ---- content ------------------------------------------------------ */

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

	.sif-media {
		position: relative;
		/* Without this a flex item's automatic minimum size is the picture's
		   own height, and the aspect-ratio below never wins. */
		min-height: 0;
		border-radius: 10px;
		overflow: clip;
		/* The measured card is 410×480; stacked on a phone it keeps that shape. */
		aspect-ratio: 410 / 480;
	}

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

	.sif-ph {
		display: grid;
		place-items: center;
		padding: 16px;
		border: 1px dashed rgba(10, 37, 64, 0.22);
		border-radius: 10px;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		text-align: center;
		color: rgba(10, 37, 64, 0.62);
	}

	.sif-ph-label,
	.sif-ph-hint,
	.sif-ph-spec {
		display: block;
	}

	.sif-ph-label {
		font-size: 12px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.sif-ph-hint {
		max-width: 28ch;
		margin: 8px auto;
		font-size: 14px;
		font-weight: 500;
		line-height: 1.4;
	}

	.sif-ph-spec {
		font-size: 12px;
		line-height: 1.2;
	}

	.sif-list {
		display: flex;
		flex-direction: column;
		gap: 10px;
		padding: 10px;
		border-radius: 10px;
		background: #ffffff;
	}

	.sif-row {
		border-radius: 10px;
		background: var(--sif-surface);
	}

	.sif-summary {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 24px;
		min-height: 60px;
		padding: 10px 10px 10px 18px;
		border-radius: 10px;
		cursor: pointer;
		list-style: none;
	}

	.sif-summary::-webkit-details-marker {
		display: none;
	}

	.sif-summary:focus-visible {
		outline: 2px solid var(--sif-accent);
		outline-offset: 2px;
	}

	.sif-question {
		flex: 1 1 auto;
		min-width: 0;
		font-family: var(--sif-display);
		font-weight: 400;
		/* 16px on a phone, 24px at 1200 — both measured. */
		font-size: clamp(16px, 2cqi, 24px);
		line-height: 1.05;
		letter-spacing: -0.02em;
		color: var(--sif-navy);
	}

	.sif-icon {
		position: relative;
		flex: 0 0 auto;
		width: 40px;
		height: 40px;
		border-radius: 6px;
		background: var(--sif-navy);
		transition: rotate var(--sif-close) var(--sif-ease);
	}

	.sif-icon::before,
	.sif-icon::after {
		content: "";
		position: absolute;
		top: 50%;
		left: 50%;
		width: 14px;
		height: 2px;
		margin: -1px 0 0 -7px;
		border-radius: 1px;
		background: var(--sif-surface);
	}

	.sif-icon::after {
		rotate: 90deg;
		transition: scale var(--sif-close) var(--sif-ease);
	}

	.sif-summary:hover .sif-icon {
		background: color-mix(in srgb, var(--sif-navy) 85%, var(--sif-accent));
	}

	/* Open: the whole mark turns over (the reference's scale(-1,-1)) and the
	   upright bar folds away, leaving a minus. */
	.sif-row[open]:not([data-sif-shut]) .sif-icon {
		rotate: 180deg;
		transition-duration: var(--sif-open);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-icon::after {
		scale: 1 0;
		transition-duration: var(--sif-open);
	}

	.sif-panel {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows var(--sif-close) var(--sif-ease);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-panel {
		grid-template-rows: 1fr;
		transition-duration: var(--sif-open);
	}

	.sif-panel-inner {
		overflow: hidden;
		min-height: 0;
	}

	.sif-answer {
		margin: 4px 10px 10px;
		padding: 20px;
		border-radius: 10px;
		background: #ffffff;
		font-size: 16px;
		line-height: 1.6;
		color: var(--sif-ink-soft);
		opacity: 0;
		transition: opacity var(--sif-close) var(--sif-ease);
	}

	.sif-row[open]:not([data-sif-shut]) .sif-answer {
		opacity: 1;
		transition: opacity calc(var(--sif-open) * 0.6) var(--sif-ease) calc(var(--sif-open) * 0.3);
	}

	.sif-answer p {
		margin: 0;
	}

	.sif-answer p + p {
		margin-top: 0.8em;
	}

	/* ---- wider containers --------------------------------------------- */

	@container (min-width: 560px) {
		.sif-media {
			aspect-ratio: 16 / 10;
		}
	}

	@container (min-width: 720px) {
		.sif-top {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-end;
			margin-bottom: 70px;
		}

		.sif-summary {
			gap: 40px;
		}
	}

	/* The measured desktop split: a 410px card (460 with its 50px gutter) and a
	   590px list, pushed to opposite edges. */
	@container (min-width: 900px) {
		.sif-content {
			flex-direction: row;
			justify-content: space-between;
			align-items: flex-start;
			gap: 40px;
		}

		.sif-media {
			flex: 0 1 410px;
			aspect-ratio: 410 / 480;
		}

		.sif[data-sticky] .sif-media {
			position: sticky;
			top: 50px;
		}

		.sif-list {
			flex: 0 1 590px;
		}

		.sif:not([data-has-image]) .sif-list {
			flex-basis: 100%;
		}
	}

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

	@media (prefers-reduced-motion: reduce) {
		.sif {
			--sif-open: 0.01ms;
			--sif-close: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		.sif-row,
		.sif-cta,
		.sif-answer {
			border: 1px solid CanvasText;
		}

		.sif-icon {
			border: 1px solid CanvasText;
		}

		.sif-icon::before,
		.sif-icon::after {
			background: CanvasText;
		}
	}
</style>