Component library
Back to catalog

Service accordion

A numbered list of services on a dark ground. One row opens at a time, the panel grows from nothing, and the title fills with white from its left edge.

accordiondisclosureservicesdarkdetails

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/service-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: "Service accordion"
Library page: https://astro.baysixmedia.com/components/service-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/ServiceAccordion.astro =====
---
/**
 * Service accordion
 *
 * A numbered list of services on a dark ground. One row opens at a time; the
 * panel grows from nothing, the title fills with white from its left edge,
 * and the arrow turns over.
 *
 * 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 `--sa-*` custom property on the root.
 */

export interface ServiceAccordionItem {
	/** The service. Set uppercase by the stylesheet, so write it in prose case. */
	title: string;
	/** What the service is. One short paragraph. */
	body: string;
	/** 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";
	/** Small label above the heading, with a ring before it. `""` hides both. */
	eyebrow?: string;
	/** Short line opposite the heading. `""` hides it. */
	intro?: string;
	/** The services, in order. */
	items?: ServiceAccordionItem[];
	/**
	 * Which row starts open, counting from 1. `0` — the default — opens none,
	 * which is how the reference arrives. Out-of-range values open 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` index before each title. */
	numbered?: boolean;
	/** The ground the section paints. */
	background?: string;
	/** Text colour. Every muted tone in the component is this at an opacity. */
	ink?: string;
	/** Rule between rows. */
	ruleColor?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the exclusivity group name. */
	id?: string;
}

const {
	heading = "Services",
	headingLevel = "h2",
	eyebrow = "How we help",
	intro = "",
	items = [],
	initialOpen = 0,
	allowMultiple = false,
	numbered = true,
	background = "#101010",
	ink = "#ffffff",
	ruleColor = "rgb(255 255 255 / 0.18)",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A row needs a title to click and a body to open onto; one missing either is
   dropped rather than drawn half-empty. No rows, no section: the heading and
   eyebrow alone never render. */
const list = items.filter((item) => item?.title && item?.body);

/*
 * 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 ?? `sa-${Math.random().toString(36).slice(2, 9)}`;

/* 1-based in, 0-based out. Anything outside the list opens nothing, which is
   the same as the default 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;

const style = [
	`--sa-ground:${background}`,
	`--sa-ink:${ink}`,
	`--sa-rule:${ruleColor}`,
].join(";");
---

{list.length > 0 && (
<section
	class:list={["service-accordion", className]}
	id={id}
	style={style}
	data-service-accordion
	data-sa-multiple={allowMultiple ? "1" : "0"}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : "Services"}
>
	<div class="sa-inner">
		{(eyebrow || heading || intro) && (
			<div class="sa-head">
				<div class="sa-head-main">
					{eyebrow && (
						<p class="sa-eyebrow">
							<span class="sa-ring" aria-hidden="true"></span>
							{eyebrow}
						</p>
					)}
					{heading && (
						<Heading class="sa-heading" id={`${uid}-heading`}>{heading}</Heading>
					)}
				</div>
				{intro && <p class="sa-intro">{intro}</p>}
			</div>
		)}

		{/* Ordered, because the design numbers the rows. The visible numbers are
		    aria-hidden so the position is announced once, by the list. */}
		<ol class="sa-list">
			{list.map((item, index) => (
				<li class="sa-row">
					<details
						class="sa-item"
						data-sa-item
						name={allowMultiple ? undefined : uid}
						open={index === openIndex}
					>
						<summary class="sa-summary" data-sa-summary>
							{numbered && (
								<span class="sa-number" aria-hidden="true">
									{item.number ?? String(index + 1).padStart(2, "0")}
								</span>
							)}
							{/*
							  Two copies of the title, one over the other. The upper one
							  is revealed by a clip that runs across it, which is why it
							  keeps the full width of the row rather than growing: a
							  growing box would re-wrap the text as it was revealed.
							*/}
							<span class="sa-title">
								<span class="sa-title-base">{item.title}</span>
								<span class="sa-title-fill" aria-hidden="true">{item.title}</span>
							</span>
							<span class="sa-icon" aria-hidden="true">
								<svg viewBox="0 0 24 24" fill="none" focusable="false">
									<path
										d="M12 5v14M5 12l7 7 7-7"
										stroke="currentColor"
										stroke-width="1.6"
										stroke-linecap="round"
										stroke-linejoin="round"></path>
								</svg>
							</span>
						</summary>
						<div class="sa-panel">
							<div class="sa-panel-inner">
								<p class="sa-body">{item.body}</p>
							</div>
						</div>
					</details>
				</li>
			))}
		</ol>
	</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. What the browser cannot do on its own is animate the close —
	 * `open` goes false and the panel is gone in the same frame — or hold one
	 * row open while closing another with both animations visible.
	 *
	 * So the controller takes the two over together. It suppresses the native
	 * toggle, drives `data-sa-open` for the animation, and lets `open` trail
	 * behind it by exactly the length of the transition. Exclusivity moves off
	 * the `name` attribute for the same reason: the browser's version is
	 * instant, and an instant close is the thing being replaced.
	 */
	interface Instance {
		destroy(): void;
	}

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

		const items = Array.from(
			root.querySelectorAll<HTMLDetailsElement>("[data-sa-item]"),
		);
		if (items.length === 0) return undefined;

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

		const single = root.dataset.saMultiple !== "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 item of items) {
			servedName.set(item, item.name);
			servedOpen.set(item, item.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.
			 */
			item.name = "";
			if (item.open) item.dataset.saOpen = "";
		}

		/**
		 * How long a panel takes to open or close, read from the stylesheet.
		 *
		 * `open` has to be cleared when the closing animation ends, and the
		 * animation's 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 openMs(): number {
			const raw = getComputedStyle(root)
				.getPropertyValue("--sa-open-duration")
				.trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 400;
			return raw.endsWith("ms") ? value : value * 1000;
		}

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

		function openItem(item: 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(item);
			item.open = true;
			if (reduced.matches) {
				item.dataset.saOpen = "";
				return;
			}
			/*
			 * The panel was display:none a moment ago, so its 0fr start frame
			 * does not exist yet. Reading a layout property forces it into
			 * being; without this the row would simply appear at full height.
			 */
			void item.offsetHeight;
			item.dataset.saOpen = "";
		}

		function closeItem(item: HTMLDetailsElement) {
			clearTimer(item);
			delete item.dataset.saOpen;
			if (reduced.matches) {
				item.open = false;
				return;
			}
			/* `open` trails the animation. Until it clears, the content is
			   still in the document, which is what there is to animate. */
			timers.set(
				item,
				window.setTimeout(() => {
					timers.delete(item);
					item.open = false;
				}, openMs()),
			);
		}

		for (const item of items) {
			const summary = item.querySelector<HTMLElement>("[data-sa-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 closing, not as open: the row the
					   reader is looking at is on its way out. */
					const isOpen = item.dataset.saOpen !== undefined;
					if (isOpen) {
						closeItem(item);
						return;
					}
					if (single) {
						for (const other of items) {
							if (other !== item && other.dataset.saOpen !== undefined) {
								closeItem(other);
							}
						}
					}
					openItem(item);
				},
				{ signal },
			);
		}

		function destroy() {
			for (const item of items) {
				clearTimer(item);
				delete item.dataset.saOpen;
				const name = servedName.get(item);
				if (name) item.name = name;
				item.open = servedOpen.get(item) ?? false;
			}
			timers.clear();
			delete root.dataset.saReady;
			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-service-accordion]")
			.forEach(initServiceAccordion);

	/* 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>
	.service-accordion {
		/*
		 * Every ramp below is a container query against this element rather
		 * than the viewport, so the component 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 here. `cqi` resolves against the
		 * query container's CONTENT box, so padding on this element would
		 * subtract itself from every ramp underneath it — 56px a side turned
		 * a 38px title into a 35px one — and a padding measured in `cqi`
		 * would be defining itself. The inner element carries it instead.
		 */
		container-type: inline-size;
		box-sizing: border-box;

		--sa-pad: clamp(16px, 3.89cqi, 56px);
		--sa-gap: clamp(8px, 1.67cqi, 24px);
		--sa-row-pad: clamp(14px, 1.81cqi, 26px);
		--sa-indent: clamp(28px, 4.44cqi, 64px);
		--sa-body-end: clamp(20px, 1.94cqi, 28px);
		--sa-head-gap: clamp(28px, 4.44cqi, 64px);

		--sa-heading-size: clamp(1.75rem, 4.51cqi, 4.0625rem);
		--sa-title-size: clamp(1.25rem, 2.64cqi, 2.375rem);
		--sa-number-size: clamp(0.625rem, 0.9cqi, 0.8125rem);
		--sa-body-size: clamp(0.875rem, 1.11cqi, 1rem);
		--sa-small-size: clamp(0.625rem, 0.9cqi, 0.8125rem);
		/* A point under the intro, which is how the reference separates the
		   label above the heading from the line opposite it. */
		--sa-eyebrow-size: clamp(0.625rem, 0.83cqi, 0.75rem);
		--sa-icon-size: clamp(18px, 2.22cqi, 32px);
		--sa-ring-size: clamp(14px, 1.39cqi, 20px);

		/* Not a second colour anywhere: the palette is one ink at seven
		   strengths over one ground, which is what holds it together. */
		--sa-title-rest: 0.28;
		--sa-number-rest: 0.35;
		--sa-number-open: 0.9;
		--sa-icon-rest: 0.4;
		--sa-icon-open: 1;
		--sa-body-strength: 0.65;
		--sa-intro-strength: 0.55;
		--sa-eyebrow-strength: 0.5;
		--sa-ring-strength: 0.45;

		--sa-ease: cubic-bezier(0.16, 1, 0.3, 1);
		/* The arrow is the one thing that eases in as well as out. Measured
		   on the reference, and it is what stops the turn looking flicked. */
		--sa-icon-ease: cubic-bezier(0.77, 0, 0.175, 1);
		--sa-open-duration: 400ms;
		--sa-wipe-duration: 700ms;
		--sa-fade-duration: 300ms;
		--sa-icon-duration: 400ms;

		background: var(--sa-ground);
		color: var(--sa-ink);
		/* 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;
		-webkit-font-smoothing: antialiased;
	}

	.sa-inner {
		box-sizing: border-box;
		margin-inline: auto;
		/* 1440px, the width everything above was measured at. Past it the
		   clamps have already topped out, so the column stops growing rather
		   than stretching five rows across a very wide screen. */
		max-width: 90rem;
		padding: var(--sa-pad);
	}

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

	.sa-head {
		display: grid;
		grid-template-columns: 1fr 1fr;
		align-items: end;
		margin-block-end: var(--sa-head-gap);
	}

	.sa-eyebrow {
		display: flex;
		align-items: center;
		gap: 10px;
		margin: 0 0 20px;
		font-size: var(--sa-eyebrow-size);
		letter-spacing: 0.1em;
		text-transform: uppercase;
		opacity: var(--sa-eyebrow-strength);
	}

	.sa-ring {
		flex: 0 0 auto;
		inline-size: var(--sa-ring-size);
		block-size: var(--sa-ring-size);
		border: 1.5px solid currentColor;
		border-radius: 50%;
		opacity: var(--sa-ring-strength);
	}

	.sa-heading {
		margin: 0;
		font-size: var(--sa-heading-size);
		font-weight: 400;
		line-height: 0.95;
		letter-spacing: -0.03em;
		text-transform: uppercase;
	}

	.sa-intro {
		justify-self: end;
		max-width: 300px;
		margin: 0;
		font-size: var(--sa-small-size);
		line-height: 1.65;
		letter-spacing: 0.06em;
		text-align: right;
		text-transform: uppercase;
		opacity: var(--sa-intro-strength);
	}

	@container (max-width: 900px) {
		.sa-head {
			grid-template-columns: 1fr;
			gap: 16px;
		}

		.sa-intro {
			justify-self: start;
			text-align: start;
		}
	}

	/* ---- rows ---------------------------------------------------------- */

	.sa-list {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.sa-row {
		border-block-start: 1px solid var(--sa-rule);
	}

	.sa-row:last-child {
		border-block-end: 1px solid var(--sa-rule);
	}

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

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

	.sa-summary:focus-visible {
		/* Drawn outside the row, where the rules above and below cannot
		   clip it. */
		outline: 2px solid currentColor;
		outline-offset: 3px;
	}

	.sa-number {
		flex: 0 0 auto;
		/* Aligned to the top of the title rather than centred on it, which
		   is what keeps it reading as an index and not a bullet. */
		align-self: flex-start;
		padding-block-start: 0.77em;
		font-size: var(--sa-number-size);
		letter-spacing: 0.05em;
		font-variant-numeric: tabular-nums;
		opacity: var(--sa-number-rest);
		transition: opacity var(--sa-fade-duration) var(--sa-ease);
	}

	.sa-title {
		position: relative;
		flex: 1 1 auto;
		min-width: 0;
		font-size: var(--sa-title-size);
		line-height: 1.05;
		letter-spacing: -0.025em;
		text-transform: uppercase;
	}

	.sa-title-base {
		display: block;
		opacity: var(--sa-title-rest);
	}

	.sa-title-fill {
		position: absolute;
		inset: 0;
		/*
		 * Clipped rather than resized. The copy keeps the full width of the
		 * row, so it wraps exactly where the copy underneath it wraps and the
		 * reveal is a straight edge travelling across finished text. A box
		 * that grew instead would re-wrap the words as it went. The 20 per
		 * cent of vertical slack is there so the clip never shaves a glyph.
		 */
		clip-path: inset(-20% 100% -20% 0);
		transition: clip-path var(--sa-wipe-duration) var(--sa-ease);
		/* The same words twice: do not let a selection copy them twice, and
		   do not let the upper copy swallow the summary's own clicks. */
		user-select: none;
		pointer-events: none;
	}

	.sa-summary:hover .sa-title-fill,
	.sa-summary:focus-visible .sa-title-fill,
	.sa-item[open] .sa-title-fill,
	.sa-item[data-sa-open] .sa-title-fill {
		clip-path: inset(-20% 0% -20% 0);
	}

	.sa-icon {
		flex: 0 0 auto;
		align-self: flex-start;
		display: flex;
		align-items: center;
		justify-content: center;
		inline-size: var(--sa-icon-size);
		block-size: var(--sa-icon-size);
		opacity: var(--sa-icon-rest);
		transition: opacity var(--sa-fade-duration) var(--sa-ease);
	}

	.sa-icon svg {
		inline-size: 100%;
		block-size: 100%;
		transition: transform var(--sa-icon-duration) var(--sa-icon-ease);
	}

	.sa-item[open] > .sa-summary .sa-number,
	.sa-item[data-sa-open] > .sa-summary .sa-number {
		opacity: var(--sa-number-open);
	}

	.sa-item[open] > .sa-summary .sa-icon,
	.sa-item[data-sa-open] > .sa-summary .sa-icon {
		opacity: var(--sa-icon-open);
	}

	.sa-item[open] > .sa-summary .sa-icon svg,
	.sa-item[data-sa-open] > .sa-summary .sa-icon svg {
		transform: rotate(180deg);
	}

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

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

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

	/*
	 * Two rules for one state, and the split is the whole enhancement story.
	 * Before the controller runs, `open` is the only state there is. Once it
	 * has run, the attribute leads and `open` trails it, so a closing panel
	 * can finish its animation while the element is still open — the one
	 * thing `open` on its own cannot express.
	 */
	.service-accordion:not([data-sa-ready]) .sa-item[open] .sa-panel {
		grid-template-rows: 1fr;
	}

	.service-accordion[data-sa-ready] .sa-item[data-sa-open] .sa-panel {
		grid-template-rows: 1fr;
	}

	.sa-body {
		max-width: 42.5rem;
		margin: 0;
		padding-inline-start: var(--sa-indent);
		padding-block-end: var(--sa-body-end);
		font-size: var(--sa-body-size);
		line-height: 1.75;
		opacity: var(--sa-body-strength);
	}

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * Every one of these rests at a visible value, so switching the
		 * animation off leaves the component correct rather than blank: the
		 * panel is open or shut, the title is filled or not, the arrow is
		 * turned or not. There is no hidden resting state to restore here,
		 * which is the trap this pattern usually sets.
		 */
		.sa-panel,
		.sa-title-fill,
		.sa-number,
		.sa-icon,
		.sa-icon svg {
			transition-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/*
		 * The palette is one ink at seven opacities, and forced colours
		 * replace the colour but not the opacity — which would leave the
		 * titles at 28 per cent of a system colour. Full strength instead,
		 * and the duplicate layer goes away because there is no longer a
		 * difference for it to reveal.
		 */
		.sa-title-base,
		.sa-number,
		.sa-icon,
		.sa-body,
		.sa-intro,
		.sa-eyebrow,
		.sa-ring {
			opacity: 1;
		}

		.sa-title-fill {
			display: none;
		}

		.sa-row {
			border-block-start-color: CanvasText;
		}

		.sa-row:last-child {
			border-block-end-color: CanvasText;
		}
	}
</style>

===== END src/components/ServiceAccordion.astro =====

===== FILE (supporting): src/components/ServiceAccordion.md =====
# Service accordion

A numbered list of services on a dark ground. One row opens at a time: the
panel grows from nothing, the title fills with white from its left edge, and
the arrow turns over.

Catalog page: `/components/service-accordion`

## Purpose

For the services, capabilities or FAQ section that has more to say than a grid
of cards can hold, and where the titles are worth setting large. It reads as a
list first and a set of controls second, which is the right order for content
somebody is scanning.

Use something else when every panel needs to be visible at once — this design
spends its space on one open row, and a reader comparing five descriptions
would be opening and closing all afternoon. `allowMultiple` softens that but
does not change what the layout is for.

## Files required

**`ServiceAccordion.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 `--sa-*` custom property, so it inherits
  nothing from the host page and leaks nothing back.
- No webfonts and **no network requests of its own**. It loads nothing.
- The only assumption about the host is that Astro compiles `.astro` files.

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

## Basic usage

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

<ServiceAccordion
  eyebrow="How we help"
  heading="Services"
  intro="Small teams, senior hands, and a date we actually keep."
  items={[
    {
      title: "Product design",
      body: "We draw the thing before it is built, then sit with the people who have to build it.",
    },
    {
      title: "Design systems",
      body: "One set of tokens, components that agree with each other, and documentation a new hire can follow.",
    },
  ]}
/>
```

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

Everything is optional, but the services are your content: `items` defaults to
empty, and with no rows the component renders nothing — the heading and eyebrow
alone never show. A row missing its `title` or `body` is dropped. The catalog
demo passes its five services as props.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `ServiceAccordionItem[]?` | `[]` | The services, in order. None renders nothing. |
| `heading` | `string?` | `"Services"` | Section heading. `""` hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"?` | `"h2"` | For pages where `h2` is the wrong level. |
| `eyebrow` | `string?` | `"How we help"` | Small label above the heading, with a ring before it. `""` hides both. |
| `intro` | `string?` | `""` | Short line opposite the heading. `""` hides it. |
| `initialOpen` | `number?` | `0` | 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` index before each title. |
| `background` | `string?` | `"#101010"` | The ground the section paints. |
| `ink` | `string?` | `"#ffffff"` | Text colour. Every muted tone is this at an opacity. |
| `ruleColor` | `string?` | `rgb(255 255 255 / 0.18)` | Rule between rows. |
| `class` | `string?` | — | Extra class on the root, for project-specific layout. |
| `id` | `string?` | — | Element id. Also seeds the exclusivity group name. |

### One service

| Field | Type | What it does |
| :--- | :--- | :--- |
| `title` | `string` | The service. Set uppercase by the stylesheet, so write it in prose case. |
| `body` | `string` | What the service is. One short paragraph. |
| `number` | `string?` | Overrides the generated index. Omit and it is the position, zero-padded. |

```ts
import type { ServiceAccordionItem } from "../components/ServiceAccordion.astro";
```

### No slots

The panel content is a single paragraph by design. A slot would have to be one
slot per row, which Astro spells as named slots — and a component whose API is
`slot="row-3-body"` is worse to use than one that takes an array.

## Behaviour

| Input | What happens |
| :--- | :--- |
| Click a closed row | It opens over 400ms. Whichever row was open closes over the same 400ms, at the same time. |
| Click the open row | It closes. Nothing else opens. |
| <kbd>Enter</kbd> or <kbd>Space</kbd> on a row | The same, from the keyboard. |
| <kbd>Tab</kbd> | Moves between the five rows. They are the only tab stops. |
| Hover or focus a row | Its title fills with white from the left over 700ms, and empties the same way when you leave. |
| Any row open | Its number goes to 90% and its arrow to full strength, and the arrow turns 180°. |

### 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 the two things the browser cannot do:

- **Animate the close.** Setting `open` to false removes the content in the
  same frame. So the controller suppresses the native toggle, drives a
  `data-sa-open` attribute for the animation, and lets `open` trail it by
  exactly the length of the transition — read back out of
  `--sa-open-duration`, so retuning the token moves the hand-off with it.
- **Close one row while opening another, visibly.** Exclusivity moves off the
  `name` attribute at boot for the same reason: the browser's version is
  instant, and an instant close is the thing being replaced. `name` is put
  back on teardown.

That split is why there are two CSS rules for one state. Before the controller
runs, `open` is the only state there is; after it, the attribute leads.

### The title fill

Each title is rendered twice, one copy over the other. The upper one is at
full strength and revealed by a `clip-path` that runs across it over **700ms**
on `cubic-bezier(0.16, 1, 0.3, 1)`.

The clip is the part worth understanding. The reference animates the *width*
of a box around the upper copy, which works until the title wraps — a box that
grows re-wraps the text as it is revealed, so the words move while you read
them. Clipping instead leaves the upper copy at the full width of the row, so
it wraps exactly where the copy underneath it wraps and the reveal is a
straight edge travelling across finished text.

The upper copy is `aria-hidden`, so the row's accessible name is the title
once rather than twice, and `user-select: none`, so a selection does not copy
it twice either.

## Accessibility

- Native `<details>` and `<summary>`. The expanded state, the keyboard
  handling and the focus behaviour are the browser's, not a reimplementation
  with `role="button"` and `aria-expanded` — which is what the reference does.
- The rows are an `<ol>`, so a screen reader gets "3 of 5" from the list. The
  visible `01`, `02` index is `aria-hidden` so it is not announced twice.
- **A closed panel is out of the accessibility tree**, not merely invisible.
  Verified: opening a row adds its paragraph to the tree and closing removes
  it.
- The five rows are the only tab stops. Nothing inside a panel is focusable,
  so nothing is reachable while hidden.
- Focus rings are drawn outside the row, where the rules above and below
  cannot clip them, and focus drives the same title fill that hover does.
- Under `forced-colors: active` the whole palette goes to full strength. The
  design is one ink at seven opacities, and forced colours replace the colour
  but not the opacity — which would otherwise leave the titles at 28% of a
  system colour. The duplicate fill layer is dropped there, because there is
  no longer a difference for it to reveal.

## Reduced motion

Under `prefers-reduced-motion: reduce` every transition drops to `0.01ms`:
panels open and close as a cut, the title fill is a switch, the arrow is
already turned.

The resting state is the thing to check in a component like this, and here it
is safe by construction — nothing rests at `opacity: 0` or at zero height
except a panel that is genuinely closed. Switching the animation off leaves
the component correct rather than blank, which is the trap this pattern
usually sets.

## 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 collapsed state is the browser's, so closed content stays out of the
  accessibility tree.
- No horizontal overflow.

Verified with scripting disabled, not assumed.

## Content length

- **Title** — one to three words. It is set at 38px and given the full width
  of the row, so it will not collide with the arrow, but a sentence set in
  38px uppercase stops reading as a label.
- **Body** — 20 to 45 words. Capped at 680px, so it holds two or three lines
  at desktop and four or five on a phone.
- **Intro** — one short line. Capped at 300px and set uppercase.

## Sizing

Two columns for the heading block above 900px, one below it. Every size is a
`clamp()` on container queries against the section rather than the viewport,
so it behaves the same dropped into a narrow column: the title runs 38px down
to 20px, the heading 65px down to 28px, the side padding 56px down to 16px.

Past 1440px the clamps have topped out and the column stops at `90rem` rather
than stretching five rows across a very wide screen.

Retune from CSS without touching the file:

```css
.my-section .service-accordion {
  --sa-ground: #0b0b0f;
  --sa-title-rest: 0.4;      /* louder closed rows */
  --sa-open-duration: 260ms; /* the controller reads this back */
  --sa-wipe-duration: 400ms;
}
```

## Differences from the reference

Rebuilt from a Framer page, measured rather than copied. At 1440px the grid,
the type scale, the palette, the rules and the timing are matched to
measurements of the original — every value within half a pixel. What differs:

- **The copy is original.** Nothing of the reference's service names or
  descriptions is reproduced.
- **It works without JavaScript.** The reference builds its rows from
  `<div role="button" tabindex="0">`, so with no script there is no way to
  open anything and no disclosure semantics at all.
- **The title fill is clipped rather than resized**, so a wrapping title does
  not re-wrap as it is revealed. Same duration, same easing curve.
- **It is responsive on container queries**, and the type is fluid rather than
  switching at three fixed breakpoints.
- **The title does not shrink to 13px on a phone.** The reference scales its
  whole desktop design down by about a third at its mobile breakpoint, which
  lands a section heading at 22px and a row title at 13px. This holds a floor
  of 20px for the title and 28px for the heading. It is the one measurement
  deliberately not reproduced.
- **Forced colours are handled.** The reference's opacity-based palette goes
  unreadable there.
- The reference sets everything in Inter and Helvetica Neue, both webfonts.
  This uses the system stack, because a component in this library fetches
  nothing.

===== END src/components/ServiceAccordion.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-18 at 1920, 1440, 1280, 1024, 900, 834, 768, 640, 480, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:e566d035bff68f5468826d7af2ad12efe72588b900174d2055462795101a8429 — 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
“Service accordion” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-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, 900, 834, 768, 640, 480, 390, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
23 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 15, library-wide content safety) after a no-behaviour content change: the default services list and intro became empty and moved into the registry demo props. 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 reference page by measurement, not by copying. Read at 1440px and reproduced to within half a pixel on all 28 values compared: side padding 56; heading 65px/61.75px at -1.95px letter-spacing starting at y 96; intro 13px/21.45px right-aligned in a 300px column at x 1084; list top y 221.75; row height 92.56 over a 1px rgba(255,255,255,0.18) rule; summary padding 26px 0 with a 24px gap; number 13px at 0.05em with 10px of top padding; title 38px/39.9px at -0.95px, uppercase; icon 32px with its right edge at 1384; body 16px/28px indented 64px with 28px beneath it. The one intentional divergence at this width is the eyebrow, which the reference sets a point under the intro at 12px - matched after a first pass had them equal.
  • The palette was read rather than guessed and is one ink at seven strengths over one ground: ground rgb(16,16,16), rule white at 0.18, closed title 0.28, number 0.35 rising to 0.9 when open, icon 0.4 rising to 1, body 0.65, intro 0.55, eyebrow 0.5, ring 0.45.
  • The open and close animation matches the reference's declared `grid-template-rows 0.4s cubic-bezier(0.16, 1, 0.3, 1)`. Sampled per frame on the rebuild: 0.608 of the distance at 53ms, 0.717 at 71ms, 0.891 at 125ms, settled by 344ms. The same bezier evaluated analytically gives 0.578 at 50ms, 0.725 at 74ms and 0.875 at 119ms. Measured independently off the user's 30fps recording before the build: a 92px row reaching 232px in about nine frames, decelerating with no overshoot.
  • The title fill matches the reference's declared `0.7s cubic-bezier(0.16, 1, 0.3, 1)`. Sampled on the rebuild: 0.333 of the distance at 45ms, 0.592 at 93ms, 0.751 at 142ms, 0.880 at 215ms, complete by 700ms. Verified on the pointer path with a real hover - clip-path resolves to inset(-20% 0% -20% 0px) hovered and inset(-20% 100% -20% 0px) after leaving - and on the keyboard path, where :focus-visible drives the same rule.
  • The fill is clipped where the reference resizes, and that is a fix rather than a shortcut. The reference animates the WIDTH of a box around the upper copy of the title, with `white-space: nowrap` on the text inside it; a title long enough to wrap therefore re-wraps as it is revealed, because the box it is wrapping inside is growing. Clipping leaves the upper copy at the full width of the row, so it wraps where the copy underneath wraps and the reveal is a straight edge over finished text. Same duration, same curve. Confirmed at 320px, where `MOTION AND PROTOTYPING` wraps to two lines and the fill still runs straight across both.
  • The arrow turns 180 degrees on `transform 0.4s cubic-bezier(0.77, 0, 0.175, 1)` - the one easing in the component that eases in as well as out, and the only curve here that is not the shared one. An earlier reading of the recording had the arrow not rotating at all; that was the wrong row in a cropped frame, and the live page settled it - closed rows point down at 0.4 opacity, the open row points up at full strength.
  • Exclusivity was verified as a visible cross-close, not just as a final state: opening row 3 while row 1 is open puts both in flight together, measured at 30ms in as row 1 at 148.4px falling and row 3 at 120.5px rising, both settling by 345ms. This is what the controller exists for - the browser's own `name` exclusivity closes the sibling instantly, so `name` is removed at boot and put back on teardown.
  • Works with JavaScript disabled, and this is the headline difference from the reference. Verified in a scripting-disabled context: no ready flag, the `name` attribute present on all five rows, clicking row 1 opens it to an 84px panel, clicking row 3 then closes row 1 - a complete accordion including the one-at-a-time rule, with no script at all. The reference builds its rows from div elements carrying role=button and tabindex=0, so with no script it has no disclosure and no way to open anything.
  • Keyboard: Tab reaches the five summaries and nothing else - a query for every focusable descendant returns exactly those five. Enter opens, Space closes, focus stays put. The focus ring is a 2px outline at 3px offset, drawn outside the row where the rules above and below cannot clip it.
  • Accessibility tree checked directly rather than inferred. Each row's accessible name is its title ONCE, not twice, although the title is in the markup twice - the duplicate carries aria-hidden. The visible 01..05 index is aria-hidden and the position comes from the <ol> instead, so it is announced once. Opening a row adds its paragraph to the tree; the four closed panels are absent from it, so a closed panel is genuinely out of the tree rather than merely invisible.
  • Reduced motion: all four transitions compute to 0.01ms, the row opens as a cut from 93px to 176px inside 60ms, and exclusivity still holds. The resting state is the thing that matters in a component like this and it is safe by construction - nothing here rests at zero opacity or zero height except a panel that is genuinely closed, so switching the animation off leaves it correct rather than blank.
  • Responsive at eleven widths from 1920 to 320. Two columns for the heading block down to 1024, one from 900, where the intro moves under the heading and goes left-aligned. `scrollWidth === clientWidth` at every width, and no descendant's right edge passes the section's at any of them. Past 1440 the clamps have topped out and the column stops at 90rem rather than stretching. At 320 the longest title wraps to two lines and the row grows to suit, with the arrow still aligned to the first line.
  • The mobile type scale is the one measurement deliberately not reproduced. The reference scales its whole desktop design down by about a third at its 390px breakpoint, landing the section heading at 22px and a row title at 13px. This holds a floor of 20px for the title and 28px for the heading. A 13px row title is a heading set smaller than the body text beneath it, and reproducing it would have meant shipping a section nobody can scan on a phone. Everything else about the mobile layout - single column, left-aligned intro, tighter padding, smaller icon - follows the reference.
  • Two instances on one page are independent and were checked as such: five numbered rows one-at-a-time against three unnumbered rows with `allowMultiple` on and row 2 already open. Opening row 4 on the first left the second untouched, and opening a second row on the second instance left its first one open. Zero duplicate ids, no page overflow.
  • Lifecycle: after `astro:before-swap` the ready flag is gone, no `data-sa-open` survives on either instance, the `name` attribute is back on the exclusive instance, 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 toggles one row.
  • 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. On the client-nav fixture: a row was opened, a link followed to the destination route, and the browser navigated back. Both instances came back, 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: the only `data-sa-open` attributes left were the two rows actually open, one per instance, and nothing was written to document.body. Zero console errors and zero page errors across the whole trip.
  • Forced colours are handled, which the reference does not do. The palette is one ink at seven opacities and forced-colors replaces the colour but not the opacity, which would leave the titles at 28 per cent of a system colour; the media query takes every strength to 1 and drops the duplicate fill layer, because there is then no difference for it to reveal. 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 companion files, and no network request of any kind.
  • Zero console messages of any level across the preview route, the fixture route and the component page.
  • The demo copy is original to this library; none of the reference's service names or descriptions is reproduced.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The one place that gap matters most is `::details-content`, which Chromium implements and which governs how a closed panel is hidden.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/ServiceAccordion.astro

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

---
/**
 * Service accordion
 *
 * A numbered list of services on a dark ground. One row opens at a time; the
 * panel grows from nothing, the title fills with white from its left edge,
 * and the arrow turns over.
 *
 * 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 `--sa-*` custom property on the root.
 */

export interface ServiceAccordionItem {
	/** The service. Set uppercase by the stylesheet, so write it in prose case. */
	title: string;
	/** What the service is. One short paragraph. */
	body: string;
	/** 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";
	/** Small label above the heading, with a ring before it. `""` hides both. */
	eyebrow?: string;
	/** Short line opposite the heading. `""` hides it. */
	intro?: string;
	/** The services, in order. */
	items?: ServiceAccordionItem[];
	/**
	 * Which row starts open, counting from 1. `0` — the default — opens none,
	 * which is how the reference arrives. Out-of-range values open 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` index before each title. */
	numbered?: boolean;
	/** The ground the section paints. */
	background?: string;
	/** Text colour. Every muted tone in the component is this at an opacity. */
	ink?: string;
	/** Rule between rows. */
	ruleColor?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the exclusivity group name. */
	id?: string;
}

const {
	heading = "Services",
	headingLevel = "h2",
	eyebrow = "How we help",
	intro = "",
	items = [],
	initialOpen = 0,
	allowMultiple = false,
	numbered = true,
	background = "#101010",
	ink = "#ffffff",
	ruleColor = "rgb(255 255 255 / 0.18)",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A row needs a title to click and a body to open onto; one missing either is
   dropped rather than drawn half-empty. No rows, no section: the heading and
   eyebrow alone never render. */
const list = items.filter((item) => item?.title && item?.body);

/*
 * 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 ?? `sa-${Math.random().toString(36).slice(2, 9)}`;

/* 1-based in, 0-based out. Anything outside the list opens nothing, which is
   the same as the default 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;

const style = [
	`--sa-ground:${background}`,
	`--sa-ink:${ink}`,
	`--sa-rule:${ruleColor}`,
].join(";");
---

{list.length > 0 && (
<section
	class:list={["service-accordion", className]}
	id={id}
	style={style}
	data-service-accordion
	data-sa-multiple={allowMultiple ? "1" : "0"}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : "Services"}
>
	<div class="sa-inner">
		{(eyebrow || heading || intro) && (
			<div class="sa-head">
				<div class="sa-head-main">
					{eyebrow && (
						<p class="sa-eyebrow">
							<span class="sa-ring" aria-hidden="true"></span>
							{eyebrow}
						</p>
					)}
					{heading && (
						<Heading class="sa-heading" id={`${uid}-heading`}>{heading}</Heading>
					)}
				</div>
				{intro && <p class="sa-intro">{intro}</p>}
			</div>
		)}

		{/* Ordered, because the design numbers the rows. The visible numbers are
		    aria-hidden so the position is announced once, by the list. */}
		<ol class="sa-list">
			{list.map((item, index) => (
				<li class="sa-row">
					<details
						class="sa-item"
						data-sa-item
						name={allowMultiple ? undefined : uid}
						open={index === openIndex}
					>
						<summary class="sa-summary" data-sa-summary>
							{numbered && (
								<span class="sa-number" aria-hidden="true">
									{item.number ?? String(index + 1).padStart(2, "0")}
								</span>
							)}
							{/*
							  Two copies of the title, one over the other. The upper one
							  is revealed by a clip that runs across it, which is why it
							  keeps the full width of the row rather than growing: a
							  growing box would re-wrap the text as it was revealed.
							*/}
							<span class="sa-title">
								<span class="sa-title-base">{item.title}</span>
								<span class="sa-title-fill" aria-hidden="true">{item.title}</span>
							</span>
							<span class="sa-icon" aria-hidden="true">
								<svg viewBox="0 0 24 24" fill="none" focusable="false">
									<path
										d="M12 5v14M5 12l7 7 7-7"
										stroke="currentColor"
										stroke-width="1.6"
										stroke-linecap="round"
										stroke-linejoin="round"></path>
								</svg>
							</span>
						</summary>
						<div class="sa-panel">
							<div class="sa-panel-inner">
								<p class="sa-body">{item.body}</p>
							</div>
						</div>
					</details>
				</li>
			))}
		</ol>
	</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. What the browser cannot do on its own is animate the close —
	 * `open` goes false and the panel is gone in the same frame — or hold one
	 * row open while closing another with both animations visible.
	 *
	 * So the controller takes the two over together. It suppresses the native
	 * toggle, drives `data-sa-open` for the animation, and lets `open` trail
	 * behind it by exactly the length of the transition. Exclusivity moves off
	 * the `name` attribute for the same reason: the browser's version is
	 * instant, and an instant close is the thing being replaced.
	 */
	interface Instance {
		destroy(): void;
	}

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

		const items = Array.from(
			root.querySelectorAll<HTMLDetailsElement>("[data-sa-item]"),
		);
		if (items.length === 0) return undefined;

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

		const single = root.dataset.saMultiple !== "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 item of items) {
			servedName.set(item, item.name);
			servedOpen.set(item, item.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.
			 */
			item.name = "";
			if (item.open) item.dataset.saOpen = "";
		}

		/**
		 * How long a panel takes to open or close, read from the stylesheet.
		 *
		 * `open` has to be cleared when the closing animation ends, and the
		 * animation's 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 openMs(): number {
			const raw = getComputedStyle(root)
				.getPropertyValue("--sa-open-duration")
				.trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 400;
			return raw.endsWith("ms") ? value : value * 1000;
		}

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

		function openItem(item: 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(item);
			item.open = true;
			if (reduced.matches) {
				item.dataset.saOpen = "";
				return;
			}
			/*
			 * The panel was display:none a moment ago, so its 0fr start frame
			 * does not exist yet. Reading a layout property forces it into
			 * being; without this the row would simply appear at full height.
			 */
			void item.offsetHeight;
			item.dataset.saOpen = "";
		}

		function closeItem(item: HTMLDetailsElement) {
			clearTimer(item);
			delete item.dataset.saOpen;
			if (reduced.matches) {
				item.open = false;
				return;
			}
			/* `open` trails the animation. Until it clears, the content is
			   still in the document, which is what there is to animate. */
			timers.set(
				item,
				window.setTimeout(() => {
					timers.delete(item);
					item.open = false;
				}, openMs()),
			);
		}

		for (const item of items) {
			const summary = item.querySelector<HTMLElement>("[data-sa-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 closing, not as open: the row the
					   reader is looking at is on its way out. */
					const isOpen = item.dataset.saOpen !== undefined;
					if (isOpen) {
						closeItem(item);
						return;
					}
					if (single) {
						for (const other of items) {
							if (other !== item && other.dataset.saOpen !== undefined) {
								closeItem(other);
							}
						}
					}
					openItem(item);
				},
				{ signal },
			);
		}

		function destroy() {
			for (const item of items) {
				clearTimer(item);
				delete item.dataset.saOpen;
				const name = servedName.get(item);
				if (name) item.name = name;
				item.open = servedOpen.get(item) ?? false;
			}
			timers.clear();
			delete root.dataset.saReady;
			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-service-accordion]")
			.forEach(initServiceAccordion);

	/* 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>
	.service-accordion {
		/*
		 * Every ramp below is a container query against this element rather
		 * than the viewport, so the component 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 here. `cqi` resolves against the
		 * query container's CONTENT box, so padding on this element would
		 * subtract itself from every ramp underneath it — 56px a side turned
		 * a 38px title into a 35px one — and a padding measured in `cqi`
		 * would be defining itself. The inner element carries it instead.
		 */
		container-type: inline-size;
		box-sizing: border-box;

		--sa-pad: clamp(16px, 3.89cqi, 56px);
		--sa-gap: clamp(8px, 1.67cqi, 24px);
		--sa-row-pad: clamp(14px, 1.81cqi, 26px);
		--sa-indent: clamp(28px, 4.44cqi, 64px);
		--sa-body-end: clamp(20px, 1.94cqi, 28px);
		--sa-head-gap: clamp(28px, 4.44cqi, 64px);

		--sa-heading-size: clamp(1.75rem, 4.51cqi, 4.0625rem);
		--sa-title-size: clamp(1.25rem, 2.64cqi, 2.375rem);
		--sa-number-size: clamp(0.625rem, 0.9cqi, 0.8125rem);
		--sa-body-size: clamp(0.875rem, 1.11cqi, 1rem);
		--sa-small-size: clamp(0.625rem, 0.9cqi, 0.8125rem);
		/* A point under the intro, which is how the reference separates the
		   label above the heading from the line opposite it. */
		--sa-eyebrow-size: clamp(0.625rem, 0.83cqi, 0.75rem);
		--sa-icon-size: clamp(18px, 2.22cqi, 32px);
		--sa-ring-size: clamp(14px, 1.39cqi, 20px);

		/* Not a second colour anywhere: the palette is one ink at seven
		   strengths over one ground, which is what holds it together. */
		--sa-title-rest: 0.28;
		--sa-number-rest: 0.35;
		--sa-number-open: 0.9;
		--sa-icon-rest: 0.4;
		--sa-icon-open: 1;
		--sa-body-strength: 0.65;
		--sa-intro-strength: 0.55;
		--sa-eyebrow-strength: 0.5;
		--sa-ring-strength: 0.45;

		--sa-ease: cubic-bezier(0.16, 1, 0.3, 1);
		/* The arrow is the one thing that eases in as well as out. Measured
		   on the reference, and it is what stops the turn looking flicked. */
		--sa-icon-ease: cubic-bezier(0.77, 0, 0.175, 1);
		--sa-open-duration: 400ms;
		--sa-wipe-duration: 700ms;
		--sa-fade-duration: 300ms;
		--sa-icon-duration: 400ms;

		background: var(--sa-ground);
		color: var(--sa-ink);
		/* 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;
		-webkit-font-smoothing: antialiased;
	}

	.sa-inner {
		box-sizing: border-box;
		margin-inline: auto;
		/* 1440px, the width everything above was measured at. Past it the
		   clamps have already topped out, so the column stops growing rather
		   than stretching five rows across a very wide screen. */
		max-width: 90rem;
		padding: var(--sa-pad);
	}

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

	.sa-head {
		display: grid;
		grid-template-columns: 1fr 1fr;
		align-items: end;
		margin-block-end: var(--sa-head-gap);
	}

	.sa-eyebrow {
		display: flex;
		align-items: center;
		gap: 10px;
		margin: 0 0 20px;
		font-size: var(--sa-eyebrow-size);
		letter-spacing: 0.1em;
		text-transform: uppercase;
		opacity: var(--sa-eyebrow-strength);
	}

	.sa-ring {
		flex: 0 0 auto;
		inline-size: var(--sa-ring-size);
		block-size: var(--sa-ring-size);
		border: 1.5px solid currentColor;
		border-radius: 50%;
		opacity: var(--sa-ring-strength);
	}

	.sa-heading {
		margin: 0;
		font-size: var(--sa-heading-size);
		font-weight: 400;
		line-height: 0.95;
		letter-spacing: -0.03em;
		text-transform: uppercase;
	}

	.sa-intro {
		justify-self: end;
		max-width: 300px;
		margin: 0;
		font-size: var(--sa-small-size);
		line-height: 1.65;
		letter-spacing: 0.06em;
		text-align: right;
		text-transform: uppercase;
		opacity: var(--sa-intro-strength);
	}

	@container (max-width: 900px) {
		.sa-head {
			grid-template-columns: 1fr;
			gap: 16px;
		}

		.sa-intro {
			justify-self: start;
			text-align: start;
		}
	}

	/* ---- rows ---------------------------------------------------------- */

	.sa-list {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.sa-row {
		border-block-start: 1px solid var(--sa-rule);
	}

	.sa-row:last-child {
		border-block-end: 1px solid var(--sa-rule);
	}

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

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

	.sa-summary:focus-visible {
		/* Drawn outside the row, where the rules above and below cannot
		   clip it. */
		outline: 2px solid currentColor;
		outline-offset: 3px;
	}

	.sa-number {
		flex: 0 0 auto;
		/* Aligned to the top of the title rather than centred on it, which
		   is what keeps it reading as an index and not a bullet. */
		align-self: flex-start;
		padding-block-start: 0.77em;
		font-size: var(--sa-number-size);
		letter-spacing: 0.05em;
		font-variant-numeric: tabular-nums;
		opacity: var(--sa-number-rest);
		transition: opacity var(--sa-fade-duration) var(--sa-ease);
	}

	.sa-title {
		position: relative;
		flex: 1 1 auto;
		min-width: 0;
		font-size: var(--sa-title-size);
		line-height: 1.05;
		letter-spacing: -0.025em;
		text-transform: uppercase;
	}

	.sa-title-base {
		display: block;
		opacity: var(--sa-title-rest);
	}

	.sa-title-fill {
		position: absolute;
		inset: 0;
		/*
		 * Clipped rather than resized. The copy keeps the full width of the
		 * row, so it wraps exactly where the copy underneath it wraps and the
		 * reveal is a straight edge travelling across finished text. A box
		 * that grew instead would re-wrap the words as it went. The 20 per
		 * cent of vertical slack is there so the clip never shaves a glyph.
		 */
		clip-path: inset(-20% 100% -20% 0);
		transition: clip-path var(--sa-wipe-duration) var(--sa-ease);
		/* The same words twice: do not let a selection copy them twice, and
		   do not let the upper copy swallow the summary's own clicks. */
		user-select: none;
		pointer-events: none;
	}

	.sa-summary:hover .sa-title-fill,
	.sa-summary:focus-visible .sa-title-fill,
	.sa-item[open] .sa-title-fill,
	.sa-item[data-sa-open] .sa-title-fill {
		clip-path: inset(-20% 0% -20% 0);
	}

	.sa-icon {
		flex: 0 0 auto;
		align-self: flex-start;
		display: flex;
		align-items: center;
		justify-content: center;
		inline-size: var(--sa-icon-size);
		block-size: var(--sa-icon-size);
		opacity: var(--sa-icon-rest);
		transition: opacity var(--sa-fade-duration) var(--sa-ease);
	}

	.sa-icon svg {
		inline-size: 100%;
		block-size: 100%;
		transition: transform var(--sa-icon-duration) var(--sa-icon-ease);
	}

	.sa-item[open] > .sa-summary .sa-number,
	.sa-item[data-sa-open] > .sa-summary .sa-number {
		opacity: var(--sa-number-open);
	}

	.sa-item[open] > .sa-summary .sa-icon,
	.sa-item[data-sa-open] > .sa-summary .sa-icon {
		opacity: var(--sa-icon-open);
	}

	.sa-item[open] > .sa-summary .sa-icon svg,
	.sa-item[data-sa-open] > .sa-summary .sa-icon svg {
		transform: rotate(180deg);
	}

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

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

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

	/*
	 * Two rules for one state, and the split is the whole enhancement story.
	 * Before the controller runs, `open` is the only state there is. Once it
	 * has run, the attribute leads and `open` trails it, so a closing panel
	 * can finish its animation while the element is still open — the one
	 * thing `open` on its own cannot express.
	 */
	.service-accordion:not([data-sa-ready]) .sa-item[open] .sa-panel {
		grid-template-rows: 1fr;
	}

	.service-accordion[data-sa-ready] .sa-item[data-sa-open] .sa-panel {
		grid-template-rows: 1fr;
	}

	.sa-body {
		max-width: 42.5rem;
		margin: 0;
		padding-inline-start: var(--sa-indent);
		padding-block-end: var(--sa-body-end);
		font-size: var(--sa-body-size);
		line-height: 1.75;
		opacity: var(--sa-body-strength);
	}

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * Every one of these rests at a visible value, so switching the
		 * animation off leaves the component correct rather than blank: the
		 * panel is open or shut, the title is filled or not, the arrow is
		 * turned or not. There is no hidden resting state to restore here,
		 * which is the trap this pattern usually sets.
		 */
		.sa-panel,
		.sa-title-fill,
		.sa-number,
		.sa-icon,
		.sa-icon svg {
			transition-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/*
		 * The palette is one ink at seven opacities, and forced colours
		 * replace the colour but not the opacity — which would leave the
		 * titles at 28 per cent of a system colour. Full strength instead,
		 * and the duplicate layer goes away because there is no longer a
		 * difference for it to reveal.
		 */
		.sa-title-base,
		.sa-number,
		.sa-icon,
		.sa-body,
		.sa-intro,
		.sa-eyebrow,
		.sa-ring {
			opacity: 1;
		}

		.sa-title-fill {
			display: none;
		}

		.sa-row {
			border-block-start-color: CanvasText;
		}

		.sa-row:last-child {
			border-block-end-color: CanvasText;
		}
	}
</style>