Skip to content

Accordion

An accordion shows and hides sections of content. <cinq-accordion> wires up header buttons, aria-expanded / aria-controls relationships, optional height transitions, keyboard navigation between headers, and open/close events.

Inspired by @19h47/accordion.

Terminal window
pnpm add @agencecinq/accordion

Import once:

import "@agencecinq/accordion";

Then write the WAI-ARIA markup explicitly. The component adds interactivity only.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

<cinq-accordion>
<div data-panel data-open="true" data-deselect="true">
<button
type="button"
data-header
id="scores-header"
aria-expanded="true"
aria-controls="scores-body"
>
Ability scores
</button>
<div id="scores-body" role="region" aria-labelledby="scores-header">
<div data-inner>Roll 3d6 for each ability and assign the results.</div>
</div>
</div>
<div data-panel data-open="false">
<button
type="button"
data-header
id="class-header"
aria-expanded="false"
aria-controls="class-body"
>
Class &amp; kit
</button>
<div id="class-body" role="region" aria-labelledby="class-header" hidden>
<div data-inner>Check minimum ability scores, then pick a kit for starting gear.</div>
</div>
</div>
</cinq-accordion>
Selector / attribute Required Role
<cinq-accordion> Yes Accordion container.
[data-panel] Yes Panel wrapper for each section.
[data-header] or button Yes Focusable trigger inside each panel.
aria-expanded Yes Current panel state on the trigger.
aria-controls Yes ID of the controlled region.
role="region" Yes On each panel body element.
aria-labelledby Yes On each region, referencing the header id.
hidden Yes On collapsed regions in their initial state.
[data-inner] Recommended Inner wrapper used for height measurement.
Attribute Default Description
data-multiselectable false Allow multiple panels open at once.
data-hash true Open the panel whose region id matches the URL hash.

Per panel:

Attribute Default Description
data-deselect false Allow collapsing when already open.
data-open - Reflected open state for styling.

You can nest a <cinq-accordion> inside a panel body. Each instance only controls its own direct panels. Inner headers are excluded from the outer keyboard group, and arrow keys stay scoped to the focused accordion.

Set data-hash="false" on nested hosts to avoid hash collisions.

<cinq-accordion data-hash="false">
<div data-panel data-open="true" data-deselect="true">
<button
type="button"
data-header
id="character-header"
aria-expanded="true"
aria-controls="character-body"
>
Character creation
</button>
<div id="character-body" role="region" aria-labelledby="character-header">
<div data-inner>
<p>Roll ability scores, pick a class, then flesh out your adventurer.</p>
<cinq-accordion data-hash="false">
<div data-panel data-open="true" data-deselect="true">
<button
type="button"
data-header
id="scores-header"
aria-expanded="true"
aria-controls="scores-body"
>
Ability scores
</button>
<div id="scores-body" role="region" aria-labelledby="scores-header">
<div data-inner>Roll 3d6 for each ability...</div>
</div>
</div>
</cinq-accordion>
</div>
</div>
</div>
</cinq-accordion>
Key Function
Tab / Shift + Tab Move focus through focusable elements.
Space / Enter Toggle the focused header (native button).
Arrow Up / Arrow Down Move focus between headers.
Arrow Left / Arrow Right Move focus between headers.
Home / End Focus first / last header.
Method Description
init() Binds panels and listeners (also runs on connectedCallback).
destroy() Detaches listeners and panel controllers.
closeAll() Closes every panel without emitting events.
Property Description
panels Internal panel controller instances.
options Resolved multiselectable and hash options.

When you mutate the panel DOM at runtime, re-bind with destroy(), mutate, init() (same pattern as tabs).

Event Cancelable Detail Description
accordion:open Yes { el, index } Fired before a panel opens.
accordion:close Yes { el, index } Fired before a panel closes.
import { EVENTS } from "@agencecinq/utils";
$accordion.addEventListener(EVENTS.ACCORDION_OPEN, (event) => {
console.log(event.detail.index);
});

Basic accordion

One panel open at a time. Use arrow keys to move between headers,Space / Enter to toggle.

Roll 3d6 for each ability, Strength, Dexterity, Constitution, Intelligence, Wisdom, and Charisma. Then assign the totals.

Last event: -

Multiselectable

Set data-multiselectable="true" on the host to keep multiple panels open.

Extra weapon slots beyond your class list. Long sword, bow, or staff.

Languages, riding, and other NWP slots from your class and Intelligence score.

Nested accordion

Place a second <cinq-accordion> inside a panel body. Each instance only binds its own panels. Keyboard navigation stays scoped to the focused accordion. Disable hash sync on nested hosts with data-hash="false".

Roll ability scores, pick a class, then flesh out your adventurer.

Roll 3d6 for each ability, assign to Strength, Dexterity, Constitution, Intelligence, Wisdom, and Charisma.

Deselect disabled

Without data-deselect="true", clicking an open header does not collapse it. Typical accordion behaviour.

Try clicking this header again. It stays open until you select another class.

const accordion = document.querySelector("cinq-accordion");
accordion.destroy(); // unbind
accordion.init(); // re-bind

Destroy / Create

Useful when you mutate the DOM and want to re-bind events. Pattern: destroy(), mutate,init().

Trolls regenerate 3 hp per round unless burned or acid-treated.

Status : Bound. Headers respond

On narrow viewports you may want every section visible as a stacked list. On desktop, the same markup becomes an accordion. Toggle with destroy() / init() (here emulated by buttons. Production code usually uses matchMedia).

const accordion = document.querySelector("cinq-accordion");
const mq = window.matchMedia("(min-width: 768px)");
const expandAsList = () => {
accordion.querySelectorAll("[data-panel]").forEach((panel) => {
const header = panel.querySelector("[data-header]");
const body = document.getElementById(header.getAttribute("aria-controls"));
panel.setAttribute("data-open", "true");
header.setAttribute("aria-expanded", "true");
body.removeAttribute("hidden");
});
};
const apply = () => {
if (mq.matches) {
accordion.init();
} else {
accordion.destroy();
expandAsList();
}
};
mq.addEventListener("change", apply);
apply();

Responsive layout

Emulate a breakpoint: on “mobile” the accordion is unbound with destroy() and reads as a stacked list; on “desktop” init() restores accordion behaviour. In production, drive this with matchMedia.

Roll 3d6 for each ability and assign the results to Strength, Dexterity, Constitution, Intelligence, Wisdom, and Charisma.

Mode : Desktop. Accordion bound

const accordion = document.querySelector("cinq-accordion");
accordion.destroy();
accordion.insertAdjacentHTML(
"beforeend",
`<div data-panel data-open="false">
<button
type="button"
data-header
id="cloaker-header"
aria-expanded="false"
aria-controls="cloaker-body"
>
Cloaker
</button>
<div id="cloaker-body" role="region" aria-labelledby="cloaker-header" hidden>
<div data-inner>Hangs from the ceiling like a shadow.</div>
</div>
</div>`,
);
accordion.init();

Add panels at runtime

The pattern is: destroy(), mutate DOM,init().

The galeb duhr is a curious boulder-like creature with appendages that act as hands and feet.