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.