The 2 files 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: "Accordion success showcase"
Library page: https://astro.baysixmedia.com/components/accordion-success-showcase/
Files: 2 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/AccordionSuccessShowcase.astro =====
---
/**
* AccordionSuccessShowcase
*
* A dark navy section with a faint tyre-track texture. Left: an orange
* eyebrow, a big white heading and an accordion list between two hairlines.
* The open item is a raised translucent card with a curved orange left border
* and an orange round arrow; closed items show their title, their line of
* text and a dark round arrow. Right: the open item's photo with a white info
* card overlapping its lower-left corner.
*
* A real accordion: each title is a <button aria-expanded aria-controls> for
* that item's showcase panel (photo + info card). One open at a time, the
* first open by default; clicking the open item keeps it open, so the photo
* column is never empty. The button's hit area is stretched over the whole
* item, so clicking anywhere on it works, as on the reference.
*
* Appear on scroll: the text side slides in from -40px, the photo side from
* +40px. Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface SuccessCard {
title: string;
text?: string;
}
export interface SuccessItem {
title: string;
text?: string;
/** This item's photo. Falls back to the section `image`. */
image?: { src?: string; alt?: string };
/** The white info card over the photo. `null` drops it. */
card?: SuccessCard | null;
}
export interface AccordionSuccessShowcaseProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Level of each item title. Defaults to one below the heading. */
itemHeadingLevel?: "h3" | "h4";
items?: SuccessItem[];
/** Shared photo for items without their own. `src: ""` = placeholder. */
image?: { src?: string; alt?: string };
/** 1-based index of the item open on load. */
initialOpen?: number;
/** The faint tyre-track texture. */
texture?: boolean;
/** The section surface. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends AccordionSuccessShowcaseProps {}
/* Items, results, heading and photo are the caller's: none are defaulted.
No items → no section. */
const {
eyebrow = "",
heading = "",
headingLevel = "h2",
itemHeadingLevel,
items = [],
image: imageProp,
initialOpen = 1,
texture = true,
background,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const uid = id ?? `ass-${Math.random().toString(36).slice(2, 8)}`;
const Heading = headingLevel;
const ItemHeading = itemHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");
/* No default photo: an item with no image of its own and no shared `image`
gets the neutral placeholder. */
const shared = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
/* An item needs a title (it is the button); a card needs a title. */
const list = (items ?? []).filter((item) => item && filled(item.title));
const openIndex = Math.min(Math.max(1, Math.round(initialOpen)), Math.max(list.length, 1)) - 1;
const rows = list.map((item, i) => {
const img = item.image ? { ...shared, ...item.image } : shared;
if (item.image?.src !== undefined && item.image.alt === undefined) img.alt = "";
return {
...item,
img,
card: item.card && filled(item.card.title) ? item.card : null,
open: i === openIndex,
btnId: `${uid}-btn-${i}`,
panelId: `${uid}-panel-${i}`,
};
});
/* Measured photo: 601 × 752 at desktop (~0.8:1); the file is 1100 × 1146. */
const IMAGE_SPEC = "~4:5 · 1100 × 1146 px";
---
{rows.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{rows.length > 0 && (
<section
class:list={["ass", className]}
id={id}
style={`--ass-rows: ${rows.length * 2 + 2};${background ? ` --ass-bg: ${background};` : ""}`}
data-ass-root
>
{
texture && (
<svg class="ass-texture" viewBox="0 0 700 760" preserveAspectRatio="xMinYMin slice" aria-hidden="true" focusable="false">
<defs>
<pattern id={`${uid}-tread`} width="64" height="30" patternUnits="userSpaceOnUse">
<path d="M2 2 32 18 62 2v9L32 27 2 11z" fill="#fff" />
</pattern>
</defs>
<g transform="rotate(24 350 380)">
<rect x="90" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
<rect x="300" y="-260" width="150" height="1300" fill={`url(#${uid}-tread)`} />
</g>
</svg>
)
}
<div class="ass-inner">
{(eyebrow || heading) && (
<div class="ass-head" data-ass-left>
{
eyebrow && (
<p class="ass-eyebrow">
{eyebrow}
<span class="ass-eyebrow-line" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="ass-heading">{heading}</Heading>}
</div>
)}
{
rows.map((row, i) => (
<>
<div
class:list={["ass-item", { "ass-item--first": i === 0, "ass-item--last": i === rows.length - 1 }]}
data-ass-item
data-ass-left
data-open={row.open ? "" : undefined}
style={`--ass-row: ${i * 2 + 2};`}
>
<ItemHeading class="ass-item-title">
<button
type="button"
class="ass-btn"
id={row.btnId}
aria-expanded={row.open ? "true" : "false"}
aria-controls={row.panelId}
data-ass-btn
>
{row.title}
</button>
</ItemHeading>
{row.text && <p class="ass-item-text">{row.text}</p>}
<span class="ass-arrow" aria-hidden="true">
<svg viewBox="0 0 10 10" width="10" height="10">
<path d="M1.5 8.5 8.5 1.5M3 1.5h5.5V7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</div>
<div
class="ass-panel"
id={row.panelId}
role="region"
aria-labelledby={row.btnId}
data-ass-panel
data-ass-right
data-open={row.open ? "" : undefined}
style={`--ass-row: ${i * 2 + 3};`}
>
<div class="ass-photo">
{row.img.src ? (
<img
src={row.img.src}
alt={row.img.alt}
width="1100"
height="1146"
loading="lazy"
decoding="async"
/>
) : (
<div class="ass-ph" role="img" aria-label="Image placeholder">
<div class="ass-ph-text" aria-hidden="true">
<span class="ass-ph-label">Image</span>
<span class="ass-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)}
</div>
{row.card && (
<div class="ass-card">
<svg class="ass-card-icon" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 14V4m-3.5 3.5L11 4l3.5 3.5M20 14V4m-3.5 3.5L20 4l3.5 3.5M29 14V4m-3.5 3.5L29 4l3.5 3.5" />
<path d="M5 19h27a4 4 0 0 1 0 8H8a4 4 0 0 0 0 8h27" />
</g>
</svg>
<p class="ass-card-title">{row.card.title}</p>
{row.card.text && <p class="ass-card-text">{row.card.text}</p>}
</div>
)}
</div>
</>
))
}
</div>
</section>
)}
<style>
.ass {
--ass-navy: var(--tg-navy, #020e28);
--ass-orange: var(--tg-orange, #fd5523);
--ass-muted: var(--tg-muted, #61697d);
--ass-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--ass-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--ass-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
box-sizing: border-box;
padding: 80px 0;
background: var(--ass-bg, var(--ass-navy));
color: #fff;
font-family: var(--ass-body);
overflow: clip;
isolation: isolate;
}
.ass *,
.ass *::before,
.ass *::after {
box-sizing: border-box;
}
/* Tyre tracks: two chevron-tread bands, very faint, fading out. */
.ass-texture {
position: absolute;
top: 28%;
left: 0;
z-index: -1;
width: min(48%, 700px);
height: 72%;
opacity: 0.045;
pointer-events: none;
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
mask-image: radial-gradient(ellipse 70% 70% at 30% 55%, #000 30%, transparent 75%);
}
.ass-inner {
display: grid;
grid-template-columns: minmax(0, 1fr);
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
}
.ass-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
padding-bottom: 32px;
}
.ass .ass-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--ass-display);
text-transform: uppercase;
color: var(--ass-orange);
}
.ass-eyebrow-line {
display: none;
width: 40px;
height: 1px;
background: currentColor;
}
.ass .ass-heading {
margin: 0;
font: 600 32px/1.2 var(--ass-display);
color: #fff;
overflow-wrap: break-word;
}
/* ---- accordion items ---- */
.ass-item {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 16px;
margin-top: 20px;
padding: 16px 0 16px 18px;
border-left: 3px solid rgb(255 255 255 / 0.15);
border-radius: 20px;
cursor: pointer;
transition:
background-color 0.4s var(--ass-ease),
border-color 0.4s var(--ass-ease),
padding 0.4s var(--ass-ease);
}
.ass-item--first {
margin-top: 0;
}
.ass-item[data-open] {
border-left-color: var(--ass-orange);
background: rgb(255 255 255 / 0.07);
}
.ass .ass-item-title {
margin: 0;
font: 600 22px/34px var(--ass-display);
color: #fff;
}
.ass-btn {
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 5px 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
/* Stretch the button's hit area over the whole item. */
.ass-btn::after {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
}
.ass-btn:focus-visible {
outline: none;
}
.ass-btn:focus-visible::after {
outline: 2px solid var(--ass-orange);
outline-offset: 3px;
}
.ass .ass-item-text {
margin: 0;
padding-right: 16px;
font: 400 16px/21.04px var(--ass-body);
color: #ecf0f1;
}
.ass-arrow {
display: none;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50px;
background: rgb(255 255 255 / 0.07);
color: #fff;
transition:
background-color 0.4s var(--ass-ease),
transform 0.4s var(--ass-ease);
}
.ass-item[data-open] .ass-arrow {
background: var(--ass-orange);
}
.ass-item:hover .ass-arrow {
transform: rotate(45deg);
}
/* ---- showcase panels ---- */
.ass-panel {
position: relative;
display: none;
margin-top: 20px;
}
.ass-panel[data-open] {
display: block;
}
.ass-photo {
position: relative;
aspect-ratio: 16 / 10;
border-radius: 12px;
overflow: hidden;
background: #0b1a36;
}
.ass-photo img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ass-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
width: auto;
max-width: 330px;
margin: -48px 16px 0;
padding: 24px;
border-radius: 20px;
background: #fff;
color: var(--ass-navy);
}
.ass-card-icon {
display: block;
width: 40px;
height: 40px;
color: var(--ass-orange);
}
.ass .ass-card-title {
margin: 0;
font: 600 22px/34px var(--ass-display);
color: var(--ass-navy);
}
.ass .ass-card-text {
margin: 0;
font: 400 16px/21.04px var(--ass-body);
color: var(--ass-muted);
}
.ass-ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #0d1c3a 0 12px, #12234438 12px 24px), #0b1a36;
color: rgb(255 255 255 / 0.7);
}
.ass-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.ass-ph-label {
font: 600 12px/1 var(--ass-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ass-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--ass-body);
}
.ass-ph-spec {
font: 400 12px/1.2 var(--ass-body);
}
/* ---- appear on scroll ---- */
[data-ass-left],
[data-ass-right] {
transition:
opacity 0.9s var(--ass-ease),
transform 0.9s var(--ass-ease);
}
.ass-item[data-ass-left] {
transition:
opacity 0.9s var(--ass-ease),
transform 0.9s var(--ass-ease),
background-color 0.4s var(--ass-ease),
border-color 0.4s var(--ass-ease),
padding 0.4s var(--ass-ease);
}
.ass[data-ass-ready]:not([data-in]) [data-ass-left] {
opacity: 0;
transform: translateX(-40px);
}
.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
opacity: 0;
transform: translateX(40px);
}
/* ---- tablet and up: text left, photo right ---- */
@media (min-width: 810px) {
.ass {
padding: 100px 0;
}
.ass-inner {
grid-template-columns: minmax(0, 584px) minmax(0, 601px);
grid-template-rows: repeat(var(--ass-rows), auto) 1fr;
justify-content: space-between;
column-gap: 40px;
}
.ass-head {
grid-column: 1;
grid-row: 1;
gap: 24px;
padding-bottom: 60px;
border-bottom: 1px solid var(--ass-muted);
margin-bottom: 30px;
}
.ass-eyebrow-line {
display: block;
}
.ass .ass-heading {
font-size: 40px;
}
.ass-item {
grid-column: 1;
grid-row: var(--ass-row);
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
row-gap: 24px;
min-height: 100px;
margin-top: 30px;
padding: 0;
border-left: 0 solid transparent;
background: transparent;
}
.ass-item--first {
margin-top: 0;
}
.ass-item--last {
padding-bottom: 0;
}
.ass-item--last::before {
content: "";
position: absolute;
right: 0;
bottom: -30px;
left: 0;
height: 1px;
background: var(--ass-muted);
}
.ass-item[data-open] {
padding: 30px;
border-left-width: 3px;
background: rgb(255 255 255 / 0.07);
}
.ass .ass-item-title,
.ass .ass-item-text {
grid-column: 1;
padding-right: 16px;
}
.ass .ass-item-title {
font-size: 24px;
}
.ass-btn {
padding: 0;
min-height: 34px;
}
.ass-arrow {
display: grid;
grid-column: 2;
grid-row: 1 / span 2;
}
/* All panels share the photo column; the open one shows. */
.ass-panel,
.ass-panel[data-open] {
display: block;
grid-column: 2;
grid-row: 1 / -1;
margin-top: 0;
}
.ass-panel {
visibility: hidden;
opacity: 0;
transition:
opacity 0.5s var(--ass-ease),
transform 0.9s var(--ass-ease),
visibility 0s linear 0.5s;
}
.ass-panel[data-open] {
visibility: visible;
opacity: 1;
transition:
opacity 0.5s var(--ass-ease),
transform 0.9s var(--ass-ease),
visibility 0s;
}
.ass-photo {
height: 100%;
min-height: 560px;
aspect-ratio: auto;
}
.ass-card {
position: absolute;
bottom: 40px;
left: 40px;
width: calc(100% - 80px);
max-width: 330px;
margin: 0;
padding: 30px;
gap: 30px;
}
.ass .ass-card-title {
margin-bottom: -3px;
font-size: 24px;
}
}
@media (min-width: 1200px) {
.ass {
padding: 120px 0;
}
.ass .ass-eyebrow {
font-size: 18px;
}
.ass .ass-heading {
font-size: 48px;
line-height: 57.6px;
}
.ass-photo {
min-height: 752px;
}
}
@media (prefers-reduced-motion: reduce) {
.ass-item,
.ass-arrow,
[data-ass-left],
[data-ass-right],
.ass-item[data-ass-left] {
transition: none;
}
.ass-item:hover .ass-arrow {
transform: none;
}
.ass[data-ass-ready]:not([data-in]) [data-ass-left],
.ass[data-ass-ready]:not([data-in]) [data-ass-right] {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) and (min-width: 810px) {
.ass-panel,
.ass-panel[data-open] {
transition: none;
}
}
@media (forced-colors: active) {
.ass-item[data-open] {
border: 1px solid CanvasText;
border-left: 3px solid Highlight;
}
.ass-btn:focus-visible::after {
outline-color: Highlight;
}
}
</style>
<script>
/*
* Accordion + appear-on-scroll. One controller per instance, scoped to
* its root, aborted on astro:before-swap. Clicking a closed item opens it
* and closes the others; clicking the open one leaves it open. Arrow
* Up/Down, Home and End move focus between item buttons.
*/
function initAccordionSuccessShowcase(root: HTMLElement) {
if (root.dataset.assReady === "" || root.dataset.assBound === "") return;
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ass-btn]"));
if (!buttons.length) return;
const controller = new AbortController();
const { signal } = controller;
root.dataset.assBound = "";
const open = (index: number) => {
buttons.forEach((btn, i) => {
const isOpen = i === index;
btn.setAttribute("aria-expanded", String(isOpen));
const item = btn.closest<HTMLElement>("[data-ass-item]");
const panel = root.querySelector<HTMLElement>(`#${CSS.escape(btn.getAttribute("aria-controls") ?? "")}`);
for (const el of [item, panel]) {
if (!el) continue;
if (isOpen) el.dataset.open = "";
else delete el.dataset.open;
}
});
};
buttons.forEach((btn, i) => {
btn.addEventListener(
"click",
() => {
if (btn.getAttribute("aria-expanded") !== "true") open(i);
},
{ signal },
);
btn.addEventListener(
"keydown",
(event) => {
let next = -1;
if (event.key === "ArrowDown") next = (i + 1) % buttons.length;
else if (event.key === "ArrowUp") next = (i - 1 + buttons.length) % buttons.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = buttons.length - 1;
if (next < 0) return;
event.preventDefault();
buttons[next].focus();
},
{ signal },
);
});
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
} else if (root.dataset.in !== "") {
root.dataset.assReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
}
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.assReady;
delete root.dataset.assBound;
},
{ once: true, signal },
);
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-ass-root]")
.forEach(initAccordionSuccessShowcase);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/AccordionSuccessShowcase.astro =====
===== FILE (supporting): src/components/AccordionSuccessShowcase.md =====
# Accordion success showcase
A dark navy section with a faint tyre-track texture. Left: an orange eyebrow,
a big white heading and an accordion of results between two hairlines. The
open item is a raised translucent card with a curved orange left edge and an
orange round arrow; closed items show their title, their line of text and a
dark round arrow. Right: the open item's photo with a white info card over
its lower-left corner.
Catalog page: `/components/accordion-success-showcase`
Part of the Northpass Logistics family (`--tg-*` tokens). Home page.
## Files required
**`AccordionSuccessShowcase.astro`**: one file. No imports, no packages.
`AccordionSuccessShowcase.md`: this file.
`public/accordion-success-showcase/cranes-dusk.webp`: the catalog demo's
photo, passed as `image` (not a default). Pass your own `image`; without one
each panel shows a neutral "Image" placeholder.
## Basic usage
```astro
---
import AccordionSuccessShowcase from "../components/AccordionSuccessShowcase.astro";
---
<AccordionSuccessShowcase
heading="Freight Programs That Paid for Themselves"
items={[
{ title: "Ocean Freight", text: "Cut landed cost by 18%.", card: { title: "Vessel Schedules", text: "Weekly sailings on 40 lanes." } },
{ title: "Air Cargo", text: "Lead time down to four days.", image: { src: "/img/air.webp", alt: "Cargo plane loading" } },
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Orange label with a trailing line (line hidden on a phone). Drawn only when passed. |
| `heading` | `string?` | `""` | Drawn only when passed. With neither eyebrow nor heading the head block (and its hairline) is omitted. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `itemHeadingLevel` | `"h3" \| "h4"?` | one below | Level of the heading wrapping each item button. |
| `items` | `SuccessItem[]?` | `[]` | `{ title, text?, image?, card? }`. `card: { title, text? }` or `null`. An item without `title` is dropped, a card without `title` is not drawn. No items → the component renders nothing. |
| `image` | `{ src?, alt? }?` | none | Shared photo for items without their own. None (or `src: ""`) = neutral placeholder. |
| `initialOpen` | `number?` | `1` | 1-based item open on load. |
| `texture` | `boolean?` | `true` | The faint tyre tracks. |
| `background` | `string?` | navy | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip. |
| `class`, `id` | `string?` | | `id` also seeds the button and panel ids. |
## Behaviour
**Accordion.** Each item title is a `<button aria-expanded aria-controls>` in
a heading. It controls that item's showcase panel (`role="region"`, labelled
by the button). One item is open at a time; clicking a closed item opens it
and closes the rest; clicking the open item leaves it open, so the photo
column is never empty. The button's hit area is stretched over the whole
item (a positioned `::after`), so clicking anywhere on the item works, as on
the reference. Arrow Up / Down, Home and End move focus between items;
Enter / Space open.
**Open vs closed item (desktop).** Open: `rgba(255,255,255,.07)` card, 20px
radius, 30px padding, 3px orange left border (curving with the radius),
orange 42px arrow. Closed: no card, no padding, `rgba(255,255,255,.07)`
arrow. Background, border and padding transition over 0.4s. Hovering an item
turns its arrow 45deg.
**Panels.** From 810px every panel sits in the right column (601 × 752 at
desktop, 12px radius) and the open one cross-fades in (0.5s). The info card
is 330px wide, 40px in from the photo's left and bottom edges. On a phone the
open panel sits directly under its item (16:10 photo, card overlapping its
bottom edge) and closed panels are `display: none`.
**Appear on scroll.** The heading and items slide in from
`translateX(-40px)`, the photo side from `translateX(40px)`, 0.9s, once, when
15% is in view. Hidden only after the script sets `data-ass-ready`.
**Texture.** Two chevron-tread bands in an inline SVG pattern, rotated 24deg,
white at 4.5% opacity, faded out with a radial mask, behind the left half of
the section.
## Accessibility
- Real buttons with synced `aria-expanded`; the controlled regions are hidden
from assistive tech (`visibility: hidden` / `display: none`) when closed.
- Visible focus ring around the whole item.
- Without JavaScript the initially open item and its panel show; the other
items' text is still visible, but their photos cannot be opened.
- Reduced motion: no appear, no cross-fade, no arrow turn.
## Script
One `AbortController` per instance for the click and key listeners and the
appear observer, aborted on `astro:before-swap`. Initialises on load and on
`astro:page-load`, idempotent. Never scrolls or moves focus on load.
## Images and credits
| File | Size | Source |
| :--- | :--- | :--- |
| `cranes-dusk.webp` | 1100 × 1146 | Unsplash `yx20mpDyr2I`, Unsplash License (free commercial use, no attribution required) |
## Approximations
- **Only one photo** was supplied, so all three demo items share it and
differ by their info card. Give each item its own `image` for the
reference's per-item photos.
- **Panel transition.** The reference stacks the three image blocks 882px
apart inside a clipped frame, which suggests a vertical slide; the switch
itself was not captured (scripted clicks did not register). A cross-fade is
used instead.
- **Descriptions stay visible on closed items**, as the desktop screenshot
shows; what the accordion opens is the item's showcase panel.
- **Phone.** The reference phone layout shows every item with its photo
(all "open", orange edge on each). Here the accordion still works on a
phone: only the open item shows its photo, and closed items get a faint
left edge. The info card, absent on the phone reference, is kept below the
photo so its content is not lost.
- The tyre-track texture is redrawn, not traced; its opacity is estimated.
===== END src/components/AccordionSuccessShowcase.md =====
===== FILE (required): public/accordion-success-showcase/cranes-dusk.webp =====
[binary asset — download https://astro.baysixmedia.com/accordion-success-showcase/cranes-dusk.webp and save it to public/accordion-success-showcase/cranes-dusk.webp]
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.