Component library
Back to catalog

FAQ accordion

Numbered questions stacked as one card. The open row lifts out of the stack as a card of its own, and the rows either side round off to meet the gap.

accordionfaqdisclosuredetailscards

Open preview in a new tab

Use this component

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

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

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: "FAQ accordion"
Library page: https://astro.baysixmedia.com/components/faq-accordion/
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/FaqAccordion.astro =====
---
/**
 * FAQ accordion
 *
 * Numbered questions stacked as one card. Closed rows sit flush against each
 * other behind hairlines; the open one lifts out of the stack as a card of its
 * own, and the rows either side of the gap round off to meet it.
 *
 * Built on `<details>` and `<summary>`, so the disclosure works before any
 * script runs. The controller's job is the animation and the one-at-a-time
 * rule, not the opening itself.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--faq-*` custom property on the root.
 */

/** A run of answer text, or a link inside it. */
export type FaqAnswerPart = string | { text: string; href: string; external?: boolean };

export interface FaqAccordionItem {
	/** The question. Shown as written. */
	question: string;
	/**
	 * The answer. A plain string, or a list of runs so a link can sit inside
	 * the sentence rather than after it.
	 */
	answer: string | FaqAnswerPart[];
	/** Overrides the generated index. Omit and it is the position, zero-padded. */
	number?: string;
}

interface Props {
	/** Section heading. `""` hides it. */
	heading?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Line under the heading. `""` hides it. */
	lead?: string;
	/** The questions, in order. */
	items?: FaqAccordionItem[];
	/**
	 * Which row starts open, counting from 1. `1` by default, because a stack
	 * of shut rows does not show what the component does. `0` opens none.
	 */
	initialOpen?: number;
	/**
	 * Let more than one row stand open. Off by default: the rule is what makes
	 * this an accordion rather than a stack of disclosures.
	 */
	allowMultiple?: boolean;
	/** Show the `01`, `02` badge before each question. */
	numbered?: boolean;
	/** Link under the list, e.g. to a full FAQ page. Needs both halves. */
	moreHref?: string;
	moreLabel?: string;
	/** The ground the section paints. */
	background?: string;
	/** The card fill. */
	surface?: string;
	/** Badge text, chevron, links and focus ring. */
	accent?: string;
	/** Badge fill. A pale wash of the accent. */
	accentSoft?: string;
	/** Heading and question colour. */
	ink?: string;
	/** Lead and answer colour. */
	muted?: string;
	/** Hairline between closed rows. */
	ruleColor?: string;
	/** Card corner radius. */
	radius?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the exclusivity group name. */
	id?: string;
}

const {
	heading = "",
	headingLevel = "h2",
	lead = "",
	items = [],
	initialOpen = 1,
	allowMultiple = false,
	numbered = true,
	moreHref,
	moreLabel = "Read every question",
	background = "#eee6e0",
	surface = "#ffffff",
	accent = "#a31b48",
	accentSoft = "#fbf1f4",
	ink = "#1f1a1c",
	muted = "#666666",
	ruleColor = "#e4ddd8",
	radius = "14px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A row needs both halves: a question with no answer, or the reverse, is
   dropped rather than drawn half-empty. No rows, no section. */
const hasAnswer = (answer: FaqAccordionItem["answer"] | undefined) =>
	typeof answer === "string"
		? answer.trim() !== ""
		: Array.isArray(answer) && answer.some((part) => (typeof part === "string" ? part.trim() : part?.text?.trim()));
const list = items.filter(
	(item) => typeof item?.question === "string" && item.question.trim() !== "" && hasAnswer(item.answer),
);

/*
 * One group name per instance. Two of these on a page must not close each
 * other's rows, and `name` is a document-wide identifier.
 */
const uid = id ?? `faq-${Math.random().toString(36).slice(2, 9)}`;

/* 1-based in, 0-based out. Anything outside the list opens nothing, which is
   the same as asking for none rather than an error. */
const openIndex =
	Number.isFinite(Number(initialOpen)) &&
	Math.trunc(Number(initialOpen)) >= 1 &&
	Math.trunc(Number(initialOpen)) <= list.length
		? Math.trunc(Number(initialOpen)) - 1
		: -1;

/*
 * Only schemes that navigate. A link in an answer is author-supplied, but this
 * component is copied into other people's projects where the answers may not
 * be — so `javascript:` never reaches an href.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : undefined);

/** One answer, normalised to a list of runs so the template has one shape. */
const parts = (answer: FaqAccordionItem["answer"]): FaqAnswerPart[] =>
	typeof answer === "string" ? [answer] : answer;

const showMore = Boolean(moreHref && moreLabel && safeHref(moreHref));

const style = [
	`--faq-ground:${background}`,
	`--faq-surface:${surface}`,
	`--faq-accent:${accent}`,
	`--faq-accent-soft:${accentSoft}`,
	`--faq-ink:${ink}`,
	`--faq-muted:${muted}`,
	`--faq-rule:${ruleColor}`,
	`--faq-radius:${radius}`,
].join(";");
---

{list.length > 0 && (
<section
	class:list={["faq-accordion", className]}
	id={id}
	style={style}
	data-faq-accordion
	data-faq-multiple={allowMultiple ? "1" : "0"}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : "Frequently asked questions"}
>
	<div class="faq-inner">
		{(heading || lead) && (
			<div class="faq-head">
				{heading && <Heading class="faq-heading" id={`${uid}-heading`}>{heading}</Heading>}
				{lead && <p class="faq-lead">{lead}</p>}
			</div>
		)}

		{/* Ordered, because the design numbers the rows. The visible badges are
		    aria-hidden so the position is announced once, by the list. */}
		<ol class="faq-list">
			{list.map((item, index) => (
				<li class="faq-item">
					<details
						class="faq-row"
						data-faq-row
						name={allowMultiple ? undefined : uid}
						open={index === openIndex}
					>
						<summary class="faq-summary" data-faq-summary>
							{numbered && (
								<span class="faq-num" aria-hidden="true">
									{item.number ?? String(index + 1).padStart(2, "0")}
								</span>
							)}
							<span class="faq-question">{item.question}</span>
							{/* Drawn from two borders on a pseudo-element rather than
							    shipped as an icon file, so it inherits the accent and
							    costs no request. */}
							<span class="faq-chevron" aria-hidden="true"></span>
						</summary>
						<div class="faq-panel">
							<div class="faq-panel-inner">
								<p class="faq-answer">
									{parts(item.answer).map((part) => {
										if (typeof part === "string") return part;
										const href = safeHref(part.href);
										if (!href) return part.text;
										return (
											<a
												class="faq-a"
												href={href}
												target={part.external ? "_blank" : undefined}
												rel={part.external ? "noopener noreferrer" : undefined}
											>
												{part.text}
											</a>
										);
									})}
								</p>
							</div>
						</div>
					</details>
				</li>
			))}
		</ol>

		{showMore && (
			<p class="faq-more">
				<a class="faq-link" href={safeHref(moreHref!)}>
					{moreLabel}
					<span class="faq-arrow" aria-hidden="true">→</span>
				</a>
			</p>
		)}
	</div>
</section>
)}

<script>
	/*
	 * What this controller is and is not for.
	 *
	 * The disclosure already works: it is <details> and <summary>, and before
	 * a line of this runs a row opens, closes, takes focus and announces its
	 * state, with `name` giving the one-at-a-time rule natively.
	 *
	 * What the browser cannot do on its own is animate the close — `open` goes
	 * false and the content is gone in the same frame — or hold one row open
	 * while closing another with both animations visible.
	 *
	 * So the controller suppresses the native toggle and adds ONE attribute:
	 * `data-faq-shut`, meaning "render this as shut whatever `open` says".
	 * Every open style in the stylesheet is written against
	 * `[open]:not([data-faq-shut])`, so the same dozen rules serve both worlds.
	 * With no script the guard never appears and `[open]` is simply the open
	 * state.
	 *
	 * With script it earns its keep on both edges:
	 *
	 * - closing — it goes on first, so the card drops back into the stack
	 *   while `open` stays long enough for the animation to run out;
	 * - opening — it goes on for exactly one frame, so the panel is rendered
	 *   shut before it is asked to grow. Without that frame a row that has
	 *   never been opened has nothing to animate from and jumps.
	 */
	interface Instance {
		destroy(): void;
	}

	function initFaqAccordion(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.faqReady === "") return undefined;

		const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>("[data-faq-row]"));
		if (rows.length === 0) return undefined;

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

		const single = root.dataset.faqMultiple !== "1";
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		/* The served state, kept so teardown can put it back rather than
		   leaving whatever the reader happened to leave open. */
		const servedName = new Map<HTMLDetailsElement, string>();
		const servedOpen = new Map<HTMLDetailsElement, boolean>();
		const timers = new Map<HTMLDetailsElement, number>();

		for (const row of rows) {
			servedName.set(row, row.name);
			servedOpen.set(row, row.open);
			/*
			 * Exclusivity moves from the browser to here. Left in place, the UA
			 * would close the sibling the instant this one opened, and the
			 * closing animation would never have a frame to run in.
			 */
			row.name = "";
		}

		/**
		 * How long a row takes to close, read from the stylesheet.
		 *
		 * `open` has to be cleared when the closing animation ends, and the
		 * length lives in a custom property so a project can retune it. Reading
		 * it back is what stops the two drifting apart the first time somebody
		 * changes one of them.
		 */
		function closeMs(): number {
			const raw = getComputedStyle(root).getPropertyValue("--faq-close-duration").trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 280;
			return raw.endsWith("ms") ? value : value * 1000;
		}

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

		function openRow(row: HTMLDetailsElement) {
			/* A close may be in flight on this very row — catch it before it
			   lands and takes the row shut underneath the new open. */
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.faqShut;
				row.open = true;
				return;
			}
			/*
			 * The panel of a row that has never been opened has never been
			 * rendered, so its `0fr` start frame does not exist and the
			 * transition has nothing to run from — the row would jump to full
			 * height. Holding the guard on while `open` goes true renders the
			 * panel shut; reading a layout property commits that frame; then
			 * releasing the guard is a change the browser can animate.
			 *
			 * Rows opened once already have the frame cached, which is exactly
			 * why this defect hides: the first row tested animates, every row
			 * after it snaps.
			 */
			row.dataset.faqShut = "";
			row.open = true;
			void row.offsetHeight;
			delete row.dataset.faqShut;
		}

		function closeRow(row: HTMLDetailsElement) {
			clearTimer(row);
			if (reduced.matches) {
				row.open = false;
				return;
			}
			/* The guard drops the open styling now; `open` follows when the
			   animation has finished with it. */
			row.dataset.faqShut = "";
			timers.set(
				row,
				window.setTimeout(() => {
					timers.delete(row);
					delete row.dataset.faqShut;
					row.open = false;
				}, closeMs()),
			);
		}

		for (const row of rows) {
			const summary = row.querySelector<HTMLElement>("[data-faq-summary]");
			if (!summary) continue;

			/* Enter and Space on a <summary> arrive here too, so this one
			   listener covers pointer and keyboard alike. */
			summary.addEventListener(
				"click",
				(event) => {
					event.preventDefault();
					/* Mid-close counts as closed: the row is on its way out, and a
					   click on it should bring it back rather than shut it again. */
					const showing = row.open && row.dataset.faqShut === undefined;
					if (showing) {
						closeRow(row);
						return;
					}
					if (single) {
						for (const other of rows) {
							if (other !== row && other.open && other.dataset.faqShut === undefined) {
								closeRow(other);
							}
						}
					}
					openRow(row);
				},
				{ signal },
			);
		}

		function destroy() {
			for (const row of rows) {
				clearTimer(row);
				delete row.dataset.faqShut;
				const name = servedName.get(row);
				if (name) row.name = name;
				row.open = servedOpen.get(row) ?? false;
			}
			timers.clear();
			delete root.dataset.faqReady;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Detach every
		 * listener, return the section to its served state, and clear the ready
		 * flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

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

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.faq-accordion {
		/*
		 * The three ramps below are container queries against this element
		 * rather than the viewport, so the section reads the same dropped into
		 * a narrow column as it does full-bleed. Each coefficient is the
		 * measured desktop value divided by 1440.
		 *
		 * The padding is deliberately not on this element: `cqi` resolves
		 * against the query container's CONTENT box, so padding here would
		 * subtract itself from every ramp underneath it.
		 */
		container-type: inline-size;
		box-sizing: border-box;

		--faq-pad-block: clamp(1.5rem, 3.33cqi, 3rem);
		--faq-pad-inline: clamp(1.25rem, 3.33cqi, 3rem);
		--faq-head-gap: clamp(1.75rem, 2.76cqi, 2.485rem);
		--faq-heading-size: clamp(2rem, 2.7cqi, 2.43rem);

		/* Fixed, because the reference holds them fixed: a question set at
		   18px is already at the size it should be read at on a phone. */
		--faq-lead-size: 1.0625rem;
		--faq-question-size: 1.125rem;
		--faq-answer-size: 1rem;
		/* Derived rather than written twice, so the badge and the indent that
		   has to clear it can never disagree. */
		--faq-badge-size: 0.9375rem;
		--faq-badge: calc(var(--faq-badge-size) * 2.2);
		--faq-row-pad-block: 1rem;
		--faq-row-pad-inline: 1rem;
		--faq-row-gap: 1rem;
		/* Level with the row's own padding until there is room to clear the
		   badge; the container query below takes over from there. */
		--faq-answer-indent: var(--faq-row-pad-inline);
		--faq-touch: 52px;
		--faq-open-gap: 12px;

		--faq-shadow: 0 1px 2px rgb(63 42 50 / 0.06);
		--faq-shadow-open:
			0 10px 28px rgb(66 42 48 / 0.07),
			0 2px 6px rgb(66 42 48 / 0.04);

		/*
		 * Two curves, deliberately. Opening is a 420ms ease-in-out, which lets
		 * the card settle into the gap it just made; closing is a shorter
		 * 280ms ease-out, because a row folding away should not ask to be
		 * watched. Taken from the reference, which does the same.
		 */
		--faq-open-duration: 420ms;
		--faq-open-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--faq-close-duration: 280ms;
		--faq-close-ease: cubic-bezier(0.33, 1, 0.68, 1);

		background: var(--faq-ground);
		color: var(--faq-ink);
		padding-block: var(--faq-pad-block);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

	.faq-inner {
		box-sizing: border-box;
		max-width: 80rem;
		margin-inline: auto;
		padding-inline: var(--faq-pad-inline);
	}

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

	.faq-head {
		text-align: center;
		margin-block-end: var(--faq-head-gap);
	}

	.faq-heading {
		margin: 0;
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-heading-size);
		font-weight: 500;
		line-height: 1.1;
		letter-spacing: -0.02em;
		color: var(--faq-ink);
	}

	.faq-lead {
		max-width: 44rem;
		margin: 1rem auto 0;
		font-size: var(--faq-lead-size);
		line-height: 1.4;
		color: var(--faq-muted);
	}

	/* ---- the stack ----------------------------------------------------- */

	.faq-list {
		/* 832px: the width the rows were measured at, and about as wide as a
		   one-line question wants to be before it stops scanning. */
		max-width: 52rem;
		margin: 0 auto;
		padding: 0;
		list-style: none;
	}

	.faq-row {
		background: var(--faq-surface);
		border-radius: 0;
		box-shadow: var(--faq-shadow);
		/*
		 * Everything that moves when a row opens is listed here, on the close
		 * curve. The open state overrides the duration and the curve rather
		 * than the property list, so the two directions cannot drift apart.
		 */
		transition:
			margin var(--faq-close-duration) var(--faq-close-ease),
			border-radius var(--faq-close-duration) var(--faq-close-ease),
			box-shadow var(--faq-close-duration) var(--faq-close-ease),
			border-color var(--faq-close-duration) var(--faq-close-ease);
	}

	/* A hairline between neighbours, never above the first row. */
	.faq-item + .faq-item .faq-row {
		border-block-start: 1px solid var(--faq-rule);
	}

	.faq-item:first-child .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:last-child .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * The open row leaves the stack.
	 *
	 * `[open]:not([data-faq-shut])` is the one condition every open style
	 * is written against. With no script the guard never appears and this is
	 * just `[open]`; with script it goes on at the START of a close, so the
	 * card drops back into the stack on the close curve while its content is
	 * still there to animate.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block: var(--faq-open-gap);
		border-radius: var(--faq-radius);
		box-shadow: var(--faq-shadow-open);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* No outer gap at the ends of the list — there is nothing to part from. */
	.faq-item:first-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-start: 0;
	}

	.faq-item:last-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-end: 0;
	}

	/* The rows either side of the gap become ends of their own group. */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:has(+ .faq-item > .faq-row[open]:not([data-faq-shut])) .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * A hairline is for two rows that touch. Either side of the gap they no
	 * longer do, so it fades rather than being removed — a rule that vanished
	 * on the first frame would read as a flicker against a 420ms move.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row,
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-block-start-color: transparent;
	}

	/* ---- the summary --------------------------------------------------- */

	.faq-summary {
		display: flex;
		align-items: center;
		gap: var(--faq-row-gap);
		min-block-size: var(--faq-touch);
		padding-block: var(--faq-row-pad-block);
		padding-inline: var(--faq-row-pad-inline);
		cursor: pointer;
		/* Both spellings are needed: the pseudo-element is what actually
		   removes the marker in WebKit, the property everywhere else. */
		list-style: none;
	}

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

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

	.faq-num {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: var(--faq-badge);
		block-size: var(--faq-badge);
		border-radius: 50%;
		background: var(--faq-accent-soft);
		color: var(--faq-accent);
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-badge-size);
		font-weight: 500;
		line-height: 1;
		font-variant-numeric: tabular-nums;
	}

	.faq-question {
		flex: 1 1 0;
		min-width: 0;
		font-size: var(--faq-question-size);
		font-weight: 600;
		line-height: 1.4;
		color: var(--faq-ink);
		transition: color var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-summary:hover .faq-question,
	.faq-summary:focus-visible .faq-question {
		color: var(--faq-accent);
	}

	.faq-chevron {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: 1.5em;
		block-size: 1.5em;
	}

	.faq-chevron::after {
		content: "";
		inline-size: 10px;
		block-size: 10px;
		border-inline-end: 2px solid var(--faq-accent);
		border-block-end: 2px solid var(--faq-accent);
		/* The nudge is optical, not decorative: a chevron pointing down carries
		   its weight low, so it has to sit high to look centred, and the other
		   way round once it turns over. */
		margin-block-start: -4px;
		transform: rotate(45deg);
		transition: transform var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-row[open]:not([data-faq-shut]) .faq-chevron::after {
		margin-block-start: 4px;
		transform: rotate(-135deg);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* ---- the panel ----------------------------------------------------- */

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

	.faq-panel-inner {
		overflow: hidden;
	}

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

	.faq-answer {
		max-width: 44rem;
		margin: 0;
		padding-inline: var(--faq-row-pad-inline);
		padding-inline-start: var(--faq-answer-indent);
		padding-block-end: 1.5rem;
		font-size: var(--faq-answer-size);
		line-height: 1.65;
		color: var(--faq-muted);
		/*
		 * The text fades rather than appearing with the box. Held back for a
		 * third of the open so the card has visibly started moving first,
		 * which is what stops the answer looking like it was always there.
		 */
		opacity: 0;
		transition: opacity var(--faq-close-duration) var(--faq-close-ease);
	}

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

	.faq-a {
		color: var(--faq-accent);
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-a:hover {
		text-decoration-thickness: 2px;
	}

	.faq-a:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 2px;
		border-radius: 2px;
	}

	/* ---- the footer link ----------------------------------------------- */

	.faq-more {
		margin: 2rem 0 0;
		text-align: center;
	}

	.faq-link {
		display: inline-flex;
		align-items: baseline;
		gap: 0.4em;
		font-size: var(--faq-answer-size);
		font-weight: 500;
		line-height: 1.65;
		color: var(--faq-accent);
		text-decoration: none;
	}

	.faq-link:hover {
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-link:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.faq-arrow {
		transition: translate var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-link:hover .faq-arrow {
		translate: 0.2em 0;
	}

	/* ---- above the narrow end ------------------------------------------ */

	@container (min-width: 480px) {
		/*
		 * Declared on the inner element, not on the root. The root IS the
		 * query container, and a container query never matches the container
		 * itself — only its descendants — so the same block written one level
		 * up silently does nothing. These inherit from here to every consumer.
		 */
		.faq-inner {
			--faq-row-pad-inline: 1.5rem;
			--faq-row-gap: 1.5rem;
			/*
			 * The answer lines up under the question, not under the badge.
			 * Derived from the two measurements either side of it so it stays
			 * aligned when the padding or the badge changes — the reference
			 * resolves its own badge term against the answer's font size
			 * instead of the badge's, and lands 2.2px to the right of its
			 * question as a result.
			 */
			--faq-answer-indent: calc(var(--faq-row-pad-inline) * 2 + var(--faq-badge));
		}
	}

	/* ---- preferences --------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The two tokens, not the transitions themselves.
		 *
		 * Every duration and delay in this file is one of these or a calc on
		 * one, so collapsing them here reaches all of it — including the
		 * answer's delay, which is a fraction of the open duration. Written as
		 * `transition-duration` on a list of selectors instead, it would lose
		 * to the open-state rules below, which are more specific and come
		 * later: the row would still take 420ms with the preference on.
		 *
		 * Every one of these rests at a visible value, so switching the
		 * animation off leaves the component correct rather than blank: the
		 * row is open or shut, the answer legible or gone with it, the chevron
		 * turned or not.
		 */
		.faq-accordion {
			--faq-open-duration: 0.01ms;
			--faq-close-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/* The card, the hairline and the lift are all colour and shadow, none
		   of which survive here — so the rows get a real border instead. */
		.faq-row {
			border: 1px solid CanvasText;
		}

		.faq-item + .faq-item .faq-row {
			border-block-start: 1px solid CanvasText;
		}

		.faq-num {
			border: 1px solid CanvasText;
		}
	}
</style>

===== END src/components/FaqAccordion.astro =====

===== FILE (supporting): src/components/FaqAccordion.md =====
# FAQ accordion

Numbered questions stacked as one card. Closed rows sit flush against each
other behind hairlines; the open one lifts out of the stack as a card of its
own, and the rows either side of the gap round off to meet it.

Catalog page: `/components/faq-accordion`

## Purpose

For the handful of questions a page has to answer before somebody will act —
pricing, timing, what happens next. It reads as one object rather than a list
of controls, and the lift tells you which answer you are reading without
needing a colour change to say so.

Use something else for a long reference FAQ. Past about eight rows the stack
stops being scannable and you want a searchable page, which is what the
optional footer link is for.

## Files required

**`FaqAccordion.astro`** — one file, the whole component.

- No imports at all, not even a type.
- No npm packages, no animation library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
  component's own root element as a `--faq-*` custom property, so it inherits
  nothing from the host page and leaks nothing back.
- No webfonts and **no network requests of its own**. The chevron is drawn
  from two borders on a pseudo-element rather than shipped as an icon.
- The only assumption about the host is that Astro compiles `.astro` files.

`FaqAccordion.md` — this file. Documentation, not required to run.

## Basic usage

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

<FaqAccordion
  heading="Questions before you start?"
  lead="A few honest answers about scope, timing and what working together looks like."
  moreHref="/faq"
  moreLabel="Read every question"
  items={[
    {
      question: "How do we begin?",
      answer: [
        "Tell us roughly what you are trying to do, or ",
        { text: "send a brief", href: "/brief" },
        " if you already have one.",
      ],
    },
    {
      question: "How long does a project take?",
      answer: "Most run six to ten weeks from first call to launch.",
    },
  ]}
/>
```

No client directive, no wrapper, no init call — the component ships its own
module script and boots itself.

It paints its own background and reaches both edges, so give it the full width
of the page.

## Props

Content has no default. With no `items` — or none carrying both a question
and an answer — it renders nothing at all; a heading alone is not an FAQ.
A row missing its question or its answer is dropped.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `FaqAccordionItem[]?` | `[]` | The questions, in order. Empty renders nothing. |
| `heading` | `string?` | `""` | Section heading. `""` hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"?` | `"h2"` | For pages where `h2` is the wrong level. |
| `lead` | `string?` | `""` | Line under the heading. `""` hides it. |
| `initialOpen` | `number?` | `1` | Which row starts open, counting from 1. `0` opens none. Out of range opens none. |
| `allowMultiple` | `boolean?` | `false` | Let more than one row stand open. |
| `numbered` | `boolean?` | `true` | Show the `01`, `02` badge. |
| `moreHref` | `string?` | — | Link under the list. Needs `moreLabel` too, or nothing renders. |
| `moreLabel` | `string?` | `"Read every question"` | Its text. |
| `background` | `string?` | `#eee6e0` | The ground the section paints. |
| `surface` | `string?` | `#ffffff` | The card fill. |
| `accent` | `string?` | `#a31b48` | Badge text, chevron, links and focus ring. |
| `accentSoft` | `string?` | `#fbf1f4` | Badge fill. |
| `ink` | `string?` | `#1f1a1c` | Heading and question colour. |
| `muted` | `string?` | `#666666` | Lead and answer colour. |
| `ruleColor` | `string?` | `#e4ddd8` | Hairline between closed rows. |
| `radius` | `string?` | `14px` | Card corner radius. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. Also seeds the exclusivity group name. |

### One question

| Field | Type | What it does |
| :--- | :--- | :--- |
| `question` | `string` | Shown as written. |
| `answer` | `string \| FaqAnswerPart[]` | A plain string, or a list of runs so a link can sit inside the sentence. |
| `number` | `string?` | Overrides the generated index. |

```ts
import type { FaqAccordionItem, FaqAnswerPart } from "../components/FaqAccordion.astro";
```

### Links inside an answer

An answer is a string, or a list of runs where a run is either text or a link:

```astro
answer={[
  "Our ",
  { text: "rates and terms", href: "/terms" },
  " are published.",
  { text: "Ask us anything", href: "https://example.com", external: true },
]}
```

`external: true` adds `target="_blank"` and `rel="noopener noreferrer"`.

This is a typed list rather than an HTML string on purpose. A component copied
into somebody else's project may end up rendering answers from a CMS, and
`set:html` would make that an injection point. Any `href` that is not `http`,
`https`, `mailto`, `tel` or a path renders as plain text instead — a
`javascript:` URL never reaches the attribute.

### No slots

The answer would be the only sensible slot, and one slot per row means named
slots. A component whose API is `slot="row-3-answer"` is worse to use than one
that takes an array.

## Behaviour

| Input | What happens |
| :--- | :--- |
| Click a closed row | It opens over 420ms and lifts out of the stack. Whichever row was open closes over 280ms, at the same time. |
| Click the open row | It drops back into the stack. |
| <kbd>Enter</kbd> or <kbd>Space</kbd> on a row | The same, from the keyboard. |
| <kbd>Tab</kbd> | Moves between the rows, then any link in the row that is open, then the footer link. |
| Hover or focus a row | Its question turns the accent colour. |
| Any row open | Its chevron turns over, and the hairlines either side of the gap fade out. |

### What the browser does and what the controller does

The disclosure is `<details>` and `<summary>`. Before a line of script runs, a
row opens, closes, takes focus and announces its state, and `name` on the
`<details>` gives the one-at-a-time rule natively.

The controller exists for two things the browser cannot do: animate the close
— setting `open` to false removes the content in the same frame — and close
one row while opening another with both moves visible.

It adds exactly one attribute, `data-faq-shut`, meaning *render this as shut
whatever `open` says*. Every open style is written against
`[open]:not([data-faq-shut])`, so one set of rules serves both worlds. With no
script the guard never appears and `[open]` is simply the open state. With
script it earns its keep on both edges:

- **closing** — it goes on first, so the card drops back into the stack while
  `open` stays long enough for the animation to finish;
- **opening** — it goes on for exactly one frame. A row that has never been
  opened has never had its panel rendered, so there is no `0fr` start frame
  for the transition to run from and the row would jump to full height.
  Rendering it shut, committing that frame, then releasing the guard gives the
  browser something to animate. *This defect hides well: rows you have already
  opened once have the frame cached, so the first row you test animates and
  every fresh one snaps.*

Exclusivity moves off `name` at boot for the same reason — the browser's
version is instant. It is put back on teardown.

### Two curves

Opening is **420ms** on `cubic-bezier(0.4, 0, 0.2, 1)`; closing is **280ms**
on `cubic-bezier(0.33, 1, 0.68, 1)`. A card settling into the gap it just made
deserves the longer, more symmetric move; a row folding away should not ask to
be watched. The margin, the radius, the shadow, the hairline, the chevron and
the panel height all run on whichever of the two applies.

The answer text is separate: it fades over 60% of the open duration after a
delay of 35% of it, so the card has visibly started moving before the words
arrive. Held to a single token each, so retuning `--faq-open-duration` moves
the fade with it — and the controller reads `--faq-close-duration` back out of
the stylesheet, so the hand-off cannot drift from the animation either.

### The stack

The radius logic is the part worth understanding. Closed rows are one card:
only the first and last round off. When a row opens it takes a `12px` margin
and a full radius, which splits the stack in two — so the row *above* the gap
rounds its bottom corners and the row *below* it rounds its top corners, using
`:has()` to look at a sibling's state. The hairlines either side of the gap
fade rather than vanish, because a rule disappearing on the first frame reads
as a flicker against a 420ms move.

At the ends of the list the open row drops its outer margin: there is nothing
there to part from.

## Accessibility

- Native `<details>` and `<summary>`. The expanded state, the keyboard
  handling and the focus behaviour are the browser's.
- The rows are an `<ol>`, so a screen reader gets "3 of 4" from the list. The
  visible `01`, `02` badge is `aria-hidden` so it is not announced twice.
- **A closed answer is out of the accessibility tree and out of the tab
  order**, not merely invisible. Verified: a link inside a closed row cannot
  take focus, and only the open row's link appears in the tab sequence.
- Focus rings are the accent at 2.67px with 2px of offset, drawn outside the
  row where the hairlines cannot clip them. Focus turns the question the same
  colour hover does, so a keyboard user gets the affordance a mouse user gets.
- Rows are at least 52px tall — a 44px touch target with room around it.
- Under `forced-colors: active` the cards take a real border, because the
  card, the hairline and the lift are all colour and shadow, none of which
  survive there.

## Reduced motion

Under `prefers-reduced-motion: reduce` both duration tokens collapse to
`0.01ms`, so every move in the component becomes a cut.

The tokens are overridden rather than the transitions, and that is the only
thing that works here: written as `transition-duration` on a list of
selectors, the rule loses to the open-state rules, which are more specific and
come later. It is checked — an earlier build left the row animating for the
full 420ms with the preference on.

The resting state is safe by construction: nothing rests at `opacity: 0` or at
zero height except an answer that is genuinely closed.

## Without JavaScript

**It is a working accordion.** Not a degraded one — the same one, without the
animation.

- Rows open and close on click and on <kbd>Enter</kbd>.
- One at a time, because `name` is in the served markup.
- `initialOpen` is honoured, because it is an `open` attribute.
- The stack still regroups around the open row: the radius and margin rules
  key off `[open]`, so the card lifts out and its neighbours round off exactly
  as they do with script.
- No horizontal overflow.

Verified with scripting disabled, not assumed.

## Content length

- **Question** — up to about eight words. It wraps rather than truncating, and
  the badge and chevron stay centred on the block.
- **Answer** — 20 to 60 words. Capped at 44rem, so it holds two or three lines
  at desktop and five or six on a phone.
- **Lead** — one line, capped at 44rem and centred.

## Sizing

The card column is capped at `52rem` and centred inside a `80rem` container.
The section padding and the heading are `clamp()` ramps on container queries
against the section rather than the viewport, so it behaves the same dropped
into a narrower column; everything else is fixed, because an 18px question is
already the size it should be read at on a phone.

Below `480px` of container width the row padding and gap tighten to `1rem` and
the answer loses its indent and sits flush with the question's left edge.

Retune from CSS without touching the file:

```css
.my-section .faq-accordion {
  --faq-accent: #1f4ed8;
  --faq-accent-soft: #eef2ff;
  --faq-radius: 4px;
  --faq-open-duration: 260ms;   /* the fade and the controller follow it */
  --faq-open-gap: 20px;
}
```

## Differences from the reference

Rebuilt from a live page, measured rather than copied. At 1440px the grid, the
type scale, the palette, the shadows, the radius logic and both timing curves
are matched to measurements of the original — 26 of 28 values compared landed
within half a pixel. What differs:

- **The copy is original.** None of the reference's questions or answers is
  reproduced.
- **The answer lines up under the question.** The reference derives the
  indent that clears the badge from `2.2em` resolved against the *answer's*
  font size rather than the badge's, and lands 2.2px to the right of its own
  question. Here the badge size is one token and the indent is derived from
  it, so the two agree: measured, question and answer both start at x 385.
- **The height animates without `interpolate-size`.** The reference animates
  `block-size` on `::details-content` with `interpolate-size: allow-keywords`,
  which is Chromium-only today; this uses `grid-template-rows: 0fr → 1fr`,
  which is supported broadly. Same duration, same curve.
- **Answers take typed link runs rather than markup**, so a CMS-fed answer
  cannot inject HTML.
- **It is responsive on container queries** rather than viewport media
  queries.
- **Forced colours are handled.**
- The reference sets its heading in Newsreader and its text in Inter, both
  webfonts. This uses Georgia and the system stack, because a component in
  this library fetches nothing.

===== END src/components/FaqAccordion.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-18 at 1920, 1440, 1280, 1024, 834, 768, 640, 480, 430, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:0359bbc85829de342319d15156b558edee7737a9769ee6eb660907ac17affa5b — MATCHES the files below.

--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.

--- 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
“FAQ accordion” from my Astro Component Library:
https://astro.baysixmedia.com/components/faq-accordion/

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
2026-09-18
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-18 at 1920, 1440, 1280, 1024, 834, 768, 640, 480, 430, 390, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
22 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 15, library-wide content safety) after a no-behaviour content change: the default questions and answers, heading and lead became empty and moved into the registry demo props; an item needs both a question and an answer. Style and script blocks are byte-identical to the verified revision, and every preview, integration-test and template page was built before and after and compared — this component's pages are identical apart from per-build random ids. The no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts (no props renders nothing; one prop; one nested field). No new full verification run was performed.
  • Every check below was run against the production build served by `astro preview`, not the dev server.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px and compared on 28 values; 26 landed identical and the two that did not were deliberate. Reproduced exactly: section ground rgb(238,230,224) with 48px of block padding; a 832px card column; rows 65.67px tall on a 1px rgb(228,221,216) hairline; summary padding 16px/24px at a 24px gap over a 52px minimum; a 33px circular badge in rgb(163,27,72) on rgb(251,241,244) set in a serif at 15px; questions 18px/25.2px at weight 600 in rgb(31,26,28); answers 16px/26.4px in rgb(102,102,102); a 14px radius, a 12px gap around the open card, `0 1px 2px rgb(63 42 50 / .06)` closed against `0 10px 28px rgb(66 42 48 / .07), 0 2px 6px rgb(66 42 48 / .04)` open; a 10px chevron drawn from two 2px borders; and a 2.67px focus ring at 2px offset.
  • Two easing curves, not one, and the split is the reference's own: opening runs 420ms on cubic-bezier(0.4, 0, 0.2, 1) and closing 280ms on cubic-bezier(0.33, 1, 0.68, 1). Measured on the rebuild against the same measurement of the reference, sampling every frame. Opening: reference 0.022 of the distance at 43ms, 0.177 at 95ms, 0.472 at 146ms, 0.732 at 198ms; rebuild 0.019 at 41ms, 0.163 at 95ms, 0.490 at 150ms, 0.743 at 204ms. Closing on the rebuild: 0.435 at 52ms, 0.746 at 106ms, 0.914 at 161ms, settled by 233ms, which is ease-out-cubic over 280ms to within a per cent at every sample. Both reach the same open height of 142.46px.
  • The answer text fades on its own schedule: 60 per cent of the open duration after a delay of 35 per cent of it, which is 252ms after 147ms. Measured on the rebuild: first frame above zero at 174ms, fully opaque at 392ms. The reference declares exactly those two fractions.
  • A real defect was found in the browser and fixed before this record was written. A row that has NEVER been opened has never had its panel rendered, so no `0fr` start frame exists and `grid-template-rows` has nothing to transition from - the row jumped to full height instead of growing. Measured before the fix: a virgin row reached 100 per cent of its open height by the first sampled frame at 6ms. The defect hides unusually well, because a row opened once keeps the frame cached and animates correctly ever after - so the first row tested looked right and every fresh one snapped. Fixed by holding the shut guard across the `open` flip and committing the frame with a layout read. Measured after: 0 at 5ms, 0.094 at 77ms, 0.490 at 150ms, settled at 380ms.
  • A second real defect, in reduced motion. The preference rule set `transition-duration` on a list of selectors, and lost - the open-state rules are more specific and come later, so with the preference on the row still animated for the full 420ms. Measured before the fix: row 0.42s, panel 0.42s, answer 0.252s with a 0.147s delay. Fixed by collapsing the two duration TOKENS instead, which every transition and every calc in the file resolves through. Measured after: every duration 1e-05s including the open-state overrides, and open, close and reopen are all cuts. Reopen is the case that matters there, because a cached frame is what would have let it animate anyway.
  • Works with JavaScript disabled, and it is the same accordion rather than a lesser one. Verified in a scripting-disabled context: no ready flag, the `name` attribute present on every row, the boot-open row honoured, and clicking row 3 closed row 1 - one at a time, with no script. The stack still regroups: the radii read `14px 14px 0 0`, `0 0 14px 14px`, `14px`, `14px` with row 3 open and its 12px margin applied, because the `:has()` rules key off `[open]` rather than anything the controller sets. No overflow.
  • Keyboard: Tab reaches the rows, then the link inside whichever row is open, then the footer link, in that order. Enter and Space both toggle and focus stays put. A link inside a CLOSED row cannot take focus - checked directly by calling focus() on it and confirming it did not become activeElement - so nothing is reachable while hidden. An earlier version of that test was vacuous: it picked the first closed row, which happens to have no link in it.
  • Accessibility tree: the rows are an <ol> so position is announced by the list, and the visible 01..04 badge is aria-hidden so it is not announced twice. Semantics, expanded state and keyboard handling are the browser's, from <details> and <summary>, rather than a reimplementation on div elements.
  • Responsive at eleven widths from 1920 to 320. The card column holds 832px down to 1024 and then shrinks with the container; the heading runs 38.88px down to a 32px floor, matching the reference at both ends. The row padding and gap tighten from 24px to 16px and the answer loses its 81px indent below 480px of container width, which is the reference's own breakpoint. `scrollWidth === clientWidth` at every width and no descendant's right edge passes the section's at any of them. At 320 the questions wrap to two lines and the rows grow to suit.
  • The answer aligns under the question, which the reference does not manage: it derives the indent that clears the badge from 2.2em resolved against the answer's 16px rather than the badge's 15px, and lands 2.2px right of its own question. Here the badge size is one token and the indent is derived from it. Measured on the rebuild: question and answer both start at x 385. This is a deliberate 2.2px divergence from the reference, not a measurement error.
  • The height is animated with `grid-template-rows: 0fr -> 1fr` rather than the reference's `block-size` on `::details-content` with `interpolate-size: allow-keywords`. Same duration and same curve, but the reference's technique is Chromium-only today and would leave the panel snapping in WebKit and Gecko. Recorded as a deliberate divergence.
  • Answers take typed link runs rather than an HTML string, so a CMS-fed answer cannot inject markup, and an href outside http, https, mailto, tel or a path renders as plain text. The component has no `set:html` anywhere.
  • Two instances on one page are independent and were checked as such: four numbered rows one-at-a-time with a row open at boot, against three unnumbered rows with `allowMultiple` on and none open. Opening row 3 on the first left the second untouched; opening rows 1 and 3 on the second left BOTH open, so the exclusivity rule is genuinely per instance. Zero duplicate ids, no page overflow.
  • Lifecycle: after `astro:before-swap` the ready flag is gone, no `data-faq-shut` survives, the `name` attribute is back on the exclusive instance and correctly still absent on the allowMultiple one, and both are returned to their SERVED open state rather than whatever was left open. `astro:page-load` boots them again, and dispatching it twice more does not double the listeners - one click still produces one toggle.
  • Client navigation is recorded end-to-end-passed on the strength of a real <ClientRouter /> round trip, not of the dispatched events above - the two are different claims and only the second one certifies. A row was opened, a link followed to the destination route, and the browser navigated back. Both instances returned, both carried the ready flag, and both still worked: row 3 opened on demand and opening row 1 afterwards closed it again, so exclusivity survived the swap. Nothing was stranded and nothing was written to document.body.
  • Forced colours give the cards a real border, because the card, the hairline and the lift are all colour and shadow and none of them survive there. Declared from the stylesheet rather than observed - no forced-colors emulation was available in this session.
  • Install kit confirmed from the component's own page: one required file carried inline with its destination path, plus the README as supporting. The component declares no assets because it has none - no images, no webfonts, no icon files, and no network request of any kind. The chevron is two borders on a pseudo-element.
  • Zero console messages and zero page errors across the preview route, the fixture route, the ClientRouter route and the component page.
  • The demo copy is original to this library; none of the reference's questions or answers is reproduced.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The place that gap matters most is `:has()`, which carries the whole regrouping-around-the-open-row behaviour.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/FaqAccordion.astro

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

---
/**
 * FAQ accordion
 *
 * Numbered questions stacked as one card. Closed rows sit flush against each
 * other behind hairlines; the open one lifts out of the stack as a card of its
 * own, and the rows either side of the gap round off to meet it.
 *
 * Built on `<details>` and `<summary>`, so the disclosure works before any
 * script runs. The controller's job is the animation and the one-at-a-time
 * rule, not the opening itself.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--faq-*` custom property on the root.
 */

/** A run of answer text, or a link inside it. */
export type FaqAnswerPart = string | { text: string; href: string; external?: boolean };

export interface FaqAccordionItem {
	/** The question. Shown as written. */
	question: string;
	/**
	 * The answer. A plain string, or a list of runs so a link can sit inside
	 * the sentence rather than after it.
	 */
	answer: string | FaqAnswerPart[];
	/** Overrides the generated index. Omit and it is the position, zero-padded. */
	number?: string;
}

interface Props {
	/** Section heading. `""` hides it. */
	heading?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Line under the heading. `""` hides it. */
	lead?: string;
	/** The questions, in order. */
	items?: FaqAccordionItem[];
	/**
	 * Which row starts open, counting from 1. `1` by default, because a stack
	 * of shut rows does not show what the component does. `0` opens none.
	 */
	initialOpen?: number;
	/**
	 * Let more than one row stand open. Off by default: the rule is what makes
	 * this an accordion rather than a stack of disclosures.
	 */
	allowMultiple?: boolean;
	/** Show the `01`, `02` badge before each question. */
	numbered?: boolean;
	/** Link under the list, e.g. to a full FAQ page. Needs both halves. */
	moreHref?: string;
	moreLabel?: string;
	/** The ground the section paints. */
	background?: string;
	/** The card fill. */
	surface?: string;
	/** Badge text, chevron, links and focus ring. */
	accent?: string;
	/** Badge fill. A pale wash of the accent. */
	accentSoft?: string;
	/** Heading and question colour. */
	ink?: string;
	/** Lead and answer colour. */
	muted?: string;
	/** Hairline between closed rows. */
	ruleColor?: string;
	/** Card corner radius. */
	radius?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the exclusivity group name. */
	id?: string;
}

const {
	heading = "",
	headingLevel = "h2",
	lead = "",
	items = [],
	initialOpen = 1,
	allowMultiple = false,
	numbered = true,
	moreHref,
	moreLabel = "Read every question",
	background = "#eee6e0",
	surface = "#ffffff",
	accent = "#a31b48",
	accentSoft = "#fbf1f4",
	ink = "#1f1a1c",
	muted = "#666666",
	ruleColor = "#e4ddd8",
	radius = "14px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A row needs both halves: a question with no answer, or the reverse, is
   dropped rather than drawn half-empty. No rows, no section. */
const hasAnswer = (answer: FaqAccordionItem["answer"] | undefined) =>
	typeof answer === "string"
		? answer.trim() !== ""
		: Array.isArray(answer) && answer.some((part) => (typeof part === "string" ? part.trim() : part?.text?.trim()));
const list = items.filter(
	(item) => typeof item?.question === "string" && item.question.trim() !== "" && hasAnswer(item.answer),
);

/*
 * One group name per instance. Two of these on a page must not close each
 * other's rows, and `name` is a document-wide identifier.
 */
const uid = id ?? `faq-${Math.random().toString(36).slice(2, 9)}`;

/* 1-based in, 0-based out. Anything outside the list opens nothing, which is
   the same as asking for none rather than an error. */
const openIndex =
	Number.isFinite(Number(initialOpen)) &&
	Math.trunc(Number(initialOpen)) >= 1 &&
	Math.trunc(Number(initialOpen)) <= list.length
		? Math.trunc(Number(initialOpen)) - 1
		: -1;

/*
 * Only schemes that navigate. A link in an answer is author-supplied, but this
 * component is copied into other people's projects where the answers may not
 * be — so `javascript:` never reaches an href.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : undefined);

/** One answer, normalised to a list of runs so the template has one shape. */
const parts = (answer: FaqAccordionItem["answer"]): FaqAnswerPart[] =>
	typeof answer === "string" ? [answer] : answer;

const showMore = Boolean(moreHref && moreLabel && safeHref(moreHref));

const style = [
	`--faq-ground:${background}`,
	`--faq-surface:${surface}`,
	`--faq-accent:${accent}`,
	`--faq-accent-soft:${accentSoft}`,
	`--faq-ink:${ink}`,
	`--faq-muted:${muted}`,
	`--faq-rule:${ruleColor}`,
	`--faq-radius:${radius}`,
].join(";");
---

{list.length > 0 && (
<section
	class:list={["faq-accordion", className]}
	id={id}
	style={style}
	data-faq-accordion
	data-faq-multiple={allowMultiple ? "1" : "0"}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : "Frequently asked questions"}
>
	<div class="faq-inner">
		{(heading || lead) && (
			<div class="faq-head">
				{heading && <Heading class="faq-heading" id={`${uid}-heading`}>{heading}</Heading>}
				{lead && <p class="faq-lead">{lead}</p>}
			</div>
		)}

		{/* Ordered, because the design numbers the rows. The visible badges are
		    aria-hidden so the position is announced once, by the list. */}
		<ol class="faq-list">
			{list.map((item, index) => (
				<li class="faq-item">
					<details
						class="faq-row"
						data-faq-row
						name={allowMultiple ? undefined : uid}
						open={index === openIndex}
					>
						<summary class="faq-summary" data-faq-summary>
							{numbered && (
								<span class="faq-num" aria-hidden="true">
									{item.number ?? String(index + 1).padStart(2, "0")}
								</span>
							)}
							<span class="faq-question">{item.question}</span>
							{/* Drawn from two borders on a pseudo-element rather than
							    shipped as an icon file, so it inherits the accent and
							    costs no request. */}
							<span class="faq-chevron" aria-hidden="true"></span>
						</summary>
						<div class="faq-panel">
							<div class="faq-panel-inner">
								<p class="faq-answer">
									{parts(item.answer).map((part) => {
										if (typeof part === "string") return part;
										const href = safeHref(part.href);
										if (!href) return part.text;
										return (
											<a
												class="faq-a"
												href={href}
												target={part.external ? "_blank" : undefined}
												rel={part.external ? "noopener noreferrer" : undefined}
											>
												{part.text}
											</a>
										);
									})}
								</p>
							</div>
						</div>
					</details>
				</li>
			))}
		</ol>

		{showMore && (
			<p class="faq-more">
				<a class="faq-link" href={safeHref(moreHref!)}>
					{moreLabel}
					<span class="faq-arrow" aria-hidden="true">→</span>
				</a>
			</p>
		)}
	</div>
</section>
)}

<script>
	/*
	 * What this controller is and is not for.
	 *
	 * The disclosure already works: it is <details> and <summary>, and before
	 * a line of this runs a row opens, closes, takes focus and announces its
	 * state, with `name` giving the one-at-a-time rule natively.
	 *
	 * What the browser cannot do on its own is animate the close — `open` goes
	 * false and the content is gone in the same frame — or hold one row open
	 * while closing another with both animations visible.
	 *
	 * So the controller suppresses the native toggle and adds ONE attribute:
	 * `data-faq-shut`, meaning "render this as shut whatever `open` says".
	 * Every open style in the stylesheet is written against
	 * `[open]:not([data-faq-shut])`, so the same dozen rules serve both worlds.
	 * With no script the guard never appears and `[open]` is simply the open
	 * state.
	 *
	 * With script it earns its keep on both edges:
	 *
	 * - closing — it goes on first, so the card drops back into the stack
	 *   while `open` stays long enough for the animation to run out;
	 * - opening — it goes on for exactly one frame, so the panel is rendered
	 *   shut before it is asked to grow. Without that frame a row that has
	 *   never been opened has nothing to animate from and jumps.
	 */
	interface Instance {
		destroy(): void;
	}

	function initFaqAccordion(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.faqReady === "") return undefined;

		const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>("[data-faq-row]"));
		if (rows.length === 0) return undefined;

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

		const single = root.dataset.faqMultiple !== "1";
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		/* The served state, kept so teardown can put it back rather than
		   leaving whatever the reader happened to leave open. */
		const servedName = new Map<HTMLDetailsElement, string>();
		const servedOpen = new Map<HTMLDetailsElement, boolean>();
		const timers = new Map<HTMLDetailsElement, number>();

		for (const row of rows) {
			servedName.set(row, row.name);
			servedOpen.set(row, row.open);
			/*
			 * Exclusivity moves from the browser to here. Left in place, the UA
			 * would close the sibling the instant this one opened, and the
			 * closing animation would never have a frame to run in.
			 */
			row.name = "";
		}

		/**
		 * How long a row takes to close, read from the stylesheet.
		 *
		 * `open` has to be cleared when the closing animation ends, and the
		 * length lives in a custom property so a project can retune it. Reading
		 * it back is what stops the two drifting apart the first time somebody
		 * changes one of them.
		 */
		function closeMs(): number {
			const raw = getComputedStyle(root).getPropertyValue("--faq-close-duration").trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 280;
			return raw.endsWith("ms") ? value : value * 1000;
		}

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

		function openRow(row: HTMLDetailsElement) {
			/* A close may be in flight on this very row — catch it before it
			   lands and takes the row shut underneath the new open. */
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.faqShut;
				row.open = true;
				return;
			}
			/*
			 * The panel of a row that has never been opened has never been
			 * rendered, so its `0fr` start frame does not exist and the
			 * transition has nothing to run from — the row would jump to full
			 * height. Holding the guard on while `open` goes true renders the
			 * panel shut; reading a layout property commits that frame; then
			 * releasing the guard is a change the browser can animate.
			 *
			 * Rows opened once already have the frame cached, which is exactly
			 * why this defect hides: the first row tested animates, every row
			 * after it snaps.
			 */
			row.dataset.faqShut = "";
			row.open = true;
			void row.offsetHeight;
			delete row.dataset.faqShut;
		}

		function closeRow(row: HTMLDetailsElement) {
			clearTimer(row);
			if (reduced.matches) {
				row.open = false;
				return;
			}
			/* The guard drops the open styling now; `open` follows when the
			   animation has finished with it. */
			row.dataset.faqShut = "";
			timers.set(
				row,
				window.setTimeout(() => {
					timers.delete(row);
					delete row.dataset.faqShut;
					row.open = false;
				}, closeMs()),
			);
		}

		for (const row of rows) {
			const summary = row.querySelector<HTMLElement>("[data-faq-summary]");
			if (!summary) continue;

			/* Enter and Space on a <summary> arrive here too, so this one
			   listener covers pointer and keyboard alike. */
			summary.addEventListener(
				"click",
				(event) => {
					event.preventDefault();
					/* Mid-close counts as closed: the row is on its way out, and a
					   click on it should bring it back rather than shut it again. */
					const showing = row.open && row.dataset.faqShut === undefined;
					if (showing) {
						closeRow(row);
						return;
					}
					if (single) {
						for (const other of rows) {
							if (other !== row && other.open && other.dataset.faqShut === undefined) {
								closeRow(other);
							}
						}
					}
					openRow(row);
				},
				{ signal },
			);
		}

		function destroy() {
			for (const row of rows) {
				clearTimer(row);
				delete row.dataset.faqShut;
				const name = servedName.get(row);
				if (name) row.name = name;
				row.open = servedOpen.get(row) ?? false;
			}
			timers.clear();
			delete root.dataset.faqReady;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Detach every
		 * listener, return the section to its served state, and clear the ready
		 * flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

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

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.faq-accordion {
		/*
		 * The three ramps below are container queries against this element
		 * rather than the viewport, so the section reads the same dropped into
		 * a narrow column as it does full-bleed. Each coefficient is the
		 * measured desktop value divided by 1440.
		 *
		 * The padding is deliberately not on this element: `cqi` resolves
		 * against the query container's CONTENT box, so padding here would
		 * subtract itself from every ramp underneath it.
		 */
		container-type: inline-size;
		box-sizing: border-box;

		--faq-pad-block: clamp(1.5rem, 3.33cqi, 3rem);
		--faq-pad-inline: clamp(1.25rem, 3.33cqi, 3rem);
		--faq-head-gap: clamp(1.75rem, 2.76cqi, 2.485rem);
		--faq-heading-size: clamp(2rem, 2.7cqi, 2.43rem);

		/* Fixed, because the reference holds them fixed: a question set at
		   18px is already at the size it should be read at on a phone. */
		--faq-lead-size: 1.0625rem;
		--faq-question-size: 1.125rem;
		--faq-answer-size: 1rem;
		/* Derived rather than written twice, so the badge and the indent that
		   has to clear it can never disagree. */
		--faq-badge-size: 0.9375rem;
		--faq-badge: calc(var(--faq-badge-size) * 2.2);
		--faq-row-pad-block: 1rem;
		--faq-row-pad-inline: 1rem;
		--faq-row-gap: 1rem;
		/* Level with the row's own padding until there is room to clear the
		   badge; the container query below takes over from there. */
		--faq-answer-indent: var(--faq-row-pad-inline);
		--faq-touch: 52px;
		--faq-open-gap: 12px;

		--faq-shadow: 0 1px 2px rgb(63 42 50 / 0.06);
		--faq-shadow-open:
			0 10px 28px rgb(66 42 48 / 0.07),
			0 2px 6px rgb(66 42 48 / 0.04);

		/*
		 * Two curves, deliberately. Opening is a 420ms ease-in-out, which lets
		 * the card settle into the gap it just made; closing is a shorter
		 * 280ms ease-out, because a row folding away should not ask to be
		 * watched. Taken from the reference, which does the same.
		 */
		--faq-open-duration: 420ms;
		--faq-open-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--faq-close-duration: 280ms;
		--faq-close-ease: cubic-bezier(0.33, 1, 0.68, 1);

		background: var(--faq-ground);
		color: var(--faq-ink);
		padding-block: var(--faq-pad-block);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

	.faq-inner {
		box-sizing: border-box;
		max-width: 80rem;
		margin-inline: auto;
		padding-inline: var(--faq-pad-inline);
	}

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

	.faq-head {
		text-align: center;
		margin-block-end: var(--faq-head-gap);
	}

	.faq-heading {
		margin: 0;
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-heading-size);
		font-weight: 500;
		line-height: 1.1;
		letter-spacing: -0.02em;
		color: var(--faq-ink);
	}

	.faq-lead {
		max-width: 44rem;
		margin: 1rem auto 0;
		font-size: var(--faq-lead-size);
		line-height: 1.4;
		color: var(--faq-muted);
	}

	/* ---- the stack ----------------------------------------------------- */

	.faq-list {
		/* 832px: the width the rows were measured at, and about as wide as a
		   one-line question wants to be before it stops scanning. */
		max-width: 52rem;
		margin: 0 auto;
		padding: 0;
		list-style: none;
	}

	.faq-row {
		background: var(--faq-surface);
		border-radius: 0;
		box-shadow: var(--faq-shadow);
		/*
		 * Everything that moves when a row opens is listed here, on the close
		 * curve. The open state overrides the duration and the curve rather
		 * than the property list, so the two directions cannot drift apart.
		 */
		transition:
			margin var(--faq-close-duration) var(--faq-close-ease),
			border-radius var(--faq-close-duration) var(--faq-close-ease),
			box-shadow var(--faq-close-duration) var(--faq-close-ease),
			border-color var(--faq-close-duration) var(--faq-close-ease);
	}

	/* A hairline between neighbours, never above the first row. */
	.faq-item + .faq-item .faq-row {
		border-block-start: 1px solid var(--faq-rule);
	}

	.faq-item:first-child .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:last-child .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * The open row leaves the stack.
	 *
	 * `[open]:not([data-faq-shut])` is the one condition every open style
	 * is written against. With no script the guard never appears and this is
	 * just `[open]`; with script it goes on at the START of a close, so the
	 * card drops back into the stack on the close curve while its content is
	 * still there to animate.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block: var(--faq-open-gap);
		border-radius: var(--faq-radius);
		box-shadow: var(--faq-shadow-open);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* No outer gap at the ends of the list — there is nothing to part from. */
	.faq-item:first-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-start: 0;
	}

	.faq-item:last-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-end: 0;
	}

	/* The rows either side of the gap become ends of their own group. */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:has(+ .faq-item > .faq-row[open]:not([data-faq-shut])) .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * A hairline is for two rows that touch. Either side of the gap they no
	 * longer do, so it fades rather than being removed — a rule that vanished
	 * on the first frame would read as a flicker against a 420ms move.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row,
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-block-start-color: transparent;
	}

	/* ---- the summary --------------------------------------------------- */

	.faq-summary {
		display: flex;
		align-items: center;
		gap: var(--faq-row-gap);
		min-block-size: var(--faq-touch);
		padding-block: var(--faq-row-pad-block);
		padding-inline: var(--faq-row-pad-inline);
		cursor: pointer;
		/* Both spellings are needed: the pseudo-element is what actually
		   removes the marker in WebKit, the property everywhere else. */
		list-style: none;
	}

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

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

	.faq-num {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: var(--faq-badge);
		block-size: var(--faq-badge);
		border-radius: 50%;
		background: var(--faq-accent-soft);
		color: var(--faq-accent);
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-badge-size);
		font-weight: 500;
		line-height: 1;
		font-variant-numeric: tabular-nums;
	}

	.faq-question {
		flex: 1 1 0;
		min-width: 0;
		font-size: var(--faq-question-size);
		font-weight: 600;
		line-height: 1.4;
		color: var(--faq-ink);
		transition: color var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-summary:hover .faq-question,
	.faq-summary:focus-visible .faq-question {
		color: var(--faq-accent);
	}

	.faq-chevron {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: 1.5em;
		block-size: 1.5em;
	}

	.faq-chevron::after {
		content: "";
		inline-size: 10px;
		block-size: 10px;
		border-inline-end: 2px solid var(--faq-accent);
		border-block-end: 2px solid var(--faq-accent);
		/* The nudge is optical, not decorative: a chevron pointing down carries
		   its weight low, so it has to sit high to look centred, and the other
		   way round once it turns over. */
		margin-block-start: -4px;
		transform: rotate(45deg);
		transition: transform var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-row[open]:not([data-faq-shut]) .faq-chevron::after {
		margin-block-start: 4px;
		transform: rotate(-135deg);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* ---- the panel ----------------------------------------------------- */

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

	.faq-panel-inner {
		overflow: hidden;
	}

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

	.faq-answer {
		max-width: 44rem;
		margin: 0;
		padding-inline: var(--faq-row-pad-inline);
		padding-inline-start: var(--faq-answer-indent);
		padding-block-end: 1.5rem;
		font-size: var(--faq-answer-size);
		line-height: 1.65;
		color: var(--faq-muted);
		/*
		 * The text fades rather than appearing with the box. Held back for a
		 * third of the open so the card has visibly started moving first,
		 * which is what stops the answer looking like it was always there.
		 */
		opacity: 0;
		transition: opacity var(--faq-close-duration) var(--faq-close-ease);
	}

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

	.faq-a {
		color: var(--faq-accent);
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-a:hover {
		text-decoration-thickness: 2px;
	}

	.faq-a:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 2px;
		border-radius: 2px;
	}

	/* ---- the footer link ----------------------------------------------- */

	.faq-more {
		margin: 2rem 0 0;
		text-align: center;
	}

	.faq-link {
		display: inline-flex;
		align-items: baseline;
		gap: 0.4em;
		font-size: var(--faq-answer-size);
		font-weight: 500;
		line-height: 1.65;
		color: var(--faq-accent);
		text-decoration: none;
	}

	.faq-link:hover {
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-link:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.faq-arrow {
		transition: translate var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-link:hover .faq-arrow {
		translate: 0.2em 0;
	}

	/* ---- above the narrow end ------------------------------------------ */

	@container (min-width: 480px) {
		/*
		 * Declared on the inner element, not on the root. The root IS the
		 * query container, and a container query never matches the container
		 * itself — only its descendants — so the same block written one level
		 * up silently does nothing. These inherit from here to every consumer.
		 */
		.faq-inner {
			--faq-row-pad-inline: 1.5rem;
			--faq-row-gap: 1.5rem;
			/*
			 * The answer lines up under the question, not under the badge.
			 * Derived from the two measurements either side of it so it stays
			 * aligned when the padding or the badge changes — the reference
			 * resolves its own badge term against the answer's font size
			 * instead of the badge's, and lands 2.2px to the right of its
			 * question as a result.
			 */
			--faq-answer-indent: calc(var(--faq-row-pad-inline) * 2 + var(--faq-badge));
		}
	}

	/* ---- preferences --------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The two tokens, not the transitions themselves.
		 *
		 * Every duration and delay in this file is one of these or a calc on
		 * one, so collapsing them here reaches all of it — including the
		 * answer's delay, which is a fraction of the open duration. Written as
		 * `transition-duration` on a list of selectors instead, it would lose
		 * to the open-state rules below, which are more specific and come
		 * later: the row would still take 420ms with the preference on.
		 *
		 * Every one of these rests at a visible value, so switching the
		 * animation off leaves the component correct rather than blank: the
		 * row is open or shut, the answer legible or gone with it, the chevron
		 * turned or not.
		 */
		.faq-accordion {
			--faq-open-duration: 0.01ms;
			--faq-close-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/* The card, the hairline and the lift are all colour and shadow, none
		   of which survive here — so the rows get a real border instead. */
		.faq-row {
			border: 1px solid CanvasText;
		}

		.faq-item + .faq-item .faq-row {
			border-block-start: 1px solid CanvasText;
		}

		.faq-num {
			border: 1px solid CanvasText;
		}
	}
</style>