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.