Accordion

Mode

Skin

Theme

Overview & Philosophy

ln-accordion is applied to a parent container via data-ln-accordion wrapping multiple independent ln-toggle panels. It carries zero internal state and listens solely for bubbling ln-toggle:open events from its children. When one panel opens, it enforces mutual exclusivity by setting data-ln-toggle="close" on all open siblings—leaving all ARIA management, click handling, and smooth CSS transitions entirely to ln-toggle.

In ln-ashlar, an accordion is a pure Coordinator: a lightweight, stateless ruleset (only 38 lines of JavaScript) that sits back, listens to the DOM, and mediates between independent, highly-specialized primitives.

The Coordinator Doctrine

A coordinator never invokes instance methods directly on other components, nor does it maintain a JavaScript registry of their states. It listens for events bubbling up from the DOM, and applies decisions by writing standard HTML attributes back to the target elements. The HTML attribute is the sole contract.

The Mindset: Three Orthogonal Layers

Every accordion you see on this page is a perfect synchronization of three isolated concerns, layered gracefully on top of each other:

  1. The State Primitive (ln-toggle): Each accordion panel is an independent ln-toggle instance. It owns the binary open / close state, coordinates trigger buttons, synchronizes aria-expanded and aria-controls, and handles per-panel localStorage persistence (data-ln-persist). It knows absolutely nothing about other panels or the fact that it is inside an accordion.
  2. The Animation Engine (CSS .collapsible): The height transition is handled entirely in Vanilla CSS via the @mixin collapsible. It transitions grid-template-rows from 0fr to 1fr. No JavaScript timers, no inline element style hacks, and no framerate stuttering.
  3. The Coordinator (ln-accordion): This is the glue. It lives on the wrapper element and enforces a single, exclusive rule: "When one panel opens, all other open panels in this group must close."

How the Coordination Flows

When a user clicks a trigger to open a panel:

  1. ln-toggle intercepts the click, opens the target panel by setting its attribute to data-ln-toggle="open", and dispatches a bubbled ln-toggle:open CustomEvent.
  2. The event bubbles up to the [data-ln-accordion] wrapper. The coordinator intercepts it, queries the immediate subtree for other elements carrying [data-ln-toggle], and sets data-ln-toggle="close" on them.
  3. Each closed panel's own ln-toggle MutationObserver reacts to its attribute change, fires its standard before-close hooks, handles persistence updates, and collapses its content using CSS.

Why This Mindset is Superior

  • Zero-configuration Multi-open: If you want multiple panels to be open at the same time, you do not need to configure an options object or change JavaScript properties. You simply remove the coordinator wrapper (data-ln-accordion), and the individual ln-toggle panels work perfectly on their own.
  • Unified Execution: Because the coordinator triggers a close by writing to the attribute, the closing panel runs through the exact same state machine as a direct user click. Cancelable events (before-close) are respected, chevrons rotate back smoothly, and localStorage persistence is automatically updated.
  • Nestable by Nature: Since ownership is determined purely by DOM ancestry (using .closest('[data-ln-accordion]')), accordions can be nested inside accordions infinitely. The inner coordinator handles its group, the outer coordinator handles its group, and they remain completely oblivious to each other.

For a deeper dive into the architectural rationale, see the Data Flow Architecture, the ln-accordion README, and the architecture deep-dive.

Anatomy

The minimal shape. Every working accordion is a variation on this skeleton.

<ul data-ln-accordion>
    <li>
        <header data-ln-toggle-for="p1">
            Section 1
            <svg class="ln-icon ln-chevron"><use href="#ln-icon-arrow-down"></use></svg>
        </header>
        <section id="p1" data-ln-toggle="open" class="collapsible">
            <article class="collapsible-body"><p>Content 1.</p></article>
        </section>
    </li>

    <li>
        <header data-ln-toggle-for="p2">
            Section 2
            <svg class="ln-icon ln-chevron"><use href="#ln-icon-arrow-down"></use></svg>
        </header>
        <section id="p2" data-ln-toggle class="collapsible">
            <article class="collapsible-body"><p>Content 2.</p></article>
        </section>
    </li>
</ul>

The wrapper — <ul data-ln-accordion>

The wrapper is a plain marker — the attribute takes no value. <ul> because an accordion is, semantically, an unordered list of collapsible items. The wrapper is the listener for the bubbled ln-toggle:open event and the dispatch element for ln-accordion:change.

Each item — <li> with a trigger header and a panel

The trigger declares which panel it controls via data-ln-toggle-for="p1"; the panel declares its own id="p1". The pairing is by ID, not by DOM proximity — the <li> just keeps them visually grouped. The chevron icon inside the header is plain decoration; ln-toggle's default CSS rotates any .ln-chevron 180° when the controlled panel is open, driven by aria-expanded on the trigger.

The panel — <section data-ln-toggle class="collapsible">

Three concerns layered on one element. id="p1" is the lookup target for the trigger. data-ln-toggle creates an ln-toggle instance — its value ("open" or anything else) is the open/closed state. class="collapsible" is the CSS animation: grid-template-rows: 0fr by default, 1fr when the element carries .open (added by ln-toggle from its attribute).

Element type is <section>. It cannot be <main> — the HTML spec allows only one <main> per page.

The body — <article class="collapsible-body">

The collapse animation requires the parent (.collapsible) to have zero padding so it can animate to zero height cleanly. Padding and margins live one level deeper, on the body child. Putting padding on .collapsible directly leaves a thin always-visible strip when collapsed.

What state goes where

"Is this panel open?" lives on the panel's data-ln-toggle attribute (owned by ln-toggle). "Is the chevron rotated?" lives on the trigger's aria-expanded (owned by ln-toggle). "Did opening this panel close the others?" is transient — handled by ln-accordion reactively, with no stored state. The accordion's instance has two fields: dom and _onToggleOpen. Nothing else.

Attributes reference

ln-accordion's entire attribute surface is one marker. Every other attribute in the markup is owned by ln-toggle or by the CSS mixin.

AttributeOn elementOwned byDescription
data-ln-accordionwrapperln-accordionMarks the wrapper. Creates the coordinator instance.
data-ln-togglepanelln-toggleCreates an ln-toggle instance. Value "open" = open; anything else = closed.
data-ln-toggle-for="id"triggerln-toggleReferences the panel by ID. Click toggles it.
data-ln-persistpanelln-togglePersist this panel's state in localStorage. Per-panel.
idpanelHTMLRequired so the trigger's data-ln-toggle-for can resolve it.
class="collapsible"panelCSS mixinGrid-rows collapse animation.
class="collapsible-body"bodyCSS mixinHolds padding/margins so the parent can collapse to zero.

Example — Basic accordion

Three panels, one open by default. Try clicking each header — opening one closes the previously-open one. Open DevTools and watch data-ln-toggle flip on each <section>; that single attribute is the source of truth.

  • What is ln-ashlar?

    ln-ashlar is a unified frontend library for LiveNetworks projects. It contains an SCSS CSS framework + vanilla JS components, with zero dependencies.

  • How to use it?

    You can use it as an npm package, via git submodule, or directly with <link> and <script> tags.

    import 'ln-ashlar';
    import 'ln-ashlar/dist/ln-ashlar.css';
  • Does it have dependencies?

    ln-ashlar is fully independent. All JS components are written in vanilla JavaScript using the IIFE pattern.

Example — Headers with rich content

Triggers are full <header> elements. They can hold any markup — headings, badges, multi-line content. The whole header is the click target. Below: each panel uses <h4> in the trigger and <ul> with the prose class for the body content.

Example — Nested accordions

Nested accordions are supported. Each accordion only manages toggles whose nearest [data-ln-accordion] ancestor is itself — opening an inner panel does not affect the outer panel, and opening an outer panel does not touch the inner accordion's open/closed state. Try it: open Inner 2 and watch Outer 1 stay open; close Outer 1 and re-open it to see the inner accordion's previous state preserved.

  • Outer 1 — contains a nested accordion

    Inner accordion below — opening Inner 1 / Inner 2 does not affect Outer 1.

    • Inner 1

      Inner 1 starts open. Opening Inner 2 closes this. Opening Outer 2 leaves this state alone.

    • Inner 2

      Inner 2 starts closed.

  • Outer 2

    Opening Outer 2 closes Outer 1 (which collapses the inner accordion with it) but does not change the inner accordion's open/closed state — re-opening Outer 1 restores the inner accordion's previous state.

Events

The accordion dispatches one event per panel-open. It bubbles, is non-cancelable, and detail.target is the panel.

EventBubblesCancelableDetailDispatched when
ln-accordion:changeyesno{ target: HTMLElement }After every ln-toggle:open is processed (siblings closed; :change dispatched even when nothing needed closing)
document.addEventListener('ln-accordion:change', function (e) {
    console.log('Active panel:', e.detail.target.id);
});

The accordion does not listen to ln-toggle:close. Closing the only-open panel just leaves the accordion fully collapsed — that's intentional.

API

There is no open(panelId) or close(panelId) method. To programmatically change the active panel, set data-ln-toggle="open" directly on the target panel — ln-toggle's observer will apply the open state, the event will bubble up, and ln-accordion will close the previously-open siblings.

// Open panel2 — accordion sees the bubbled ln-toggle:open and closes panel1
document.getElementById('panel2').setAttribute('data-ln-toggle', 'open');

// Tear down the coordinator (children remain intact)
const acc = document.querySelector('[data-ln-accordion]');
acc.lnAccordion.destroy();

Common mistakes

1. Padding on .collapsible directly

The collapse animation works by transitioning grid-template-rows from 0fr to 1fr. Padding on the parent leaves a visible strip when collapsed. Padding always goes on .collapsible-body.

<!-- WRONG -->
<section data-ln-toggle class="collapsible" style="padding: 1rem;">…</section>

<!-- RIGHT -->
<section data-ln-toggle class="collapsible">
    <article class="collapsible-body">…</article>
</section>

3. Putting data-ln-toggle on the trigger header

data-ln-toggle creates a panel; data-ln-toggle-for creates a trigger. They are mutually exclusive — one element is one or the other. Putting both on the header turns the header into a panel that controls itself.

<!-- WRONG -->
<li>
    <header data-ln-toggle data-ln-toggle-for="p1">Section 1</header>
    <section id="p1" class="collapsible">…</section>
</li>

4. Panels living outside the accordion subtree

The pairing between trigger and panel is by ID, so ln-toggle works fine across the wrapper boundary. But the accordion's listener is scoped to its own subtree — bubbled ln-toggle:open events from outside don't reach it, so the single-open rule won't fire. Keep panels inside the accordion's DOM.

<!-- WRONG — panels outside the accordion -->
<ul data-ln-accordion>
    <li><header data-ln-toggle-for="p1">Section 1</header></li>
</ul>
<section id="p1" data-ln-toggle class="collapsible">…</section>

Related

  • ln-toggle — every panel in the accordion is an ln-toggle instance. Open/close API, persistence, cancelable events and aria sync all come from there.
  • @mixin collapsible — the grid-template-rows animation. .collapsible is the parent (zero padding); .collapsible-body is the padded child.
  • @mixin accordion — bordered-card chrome and chevron rotation. Already applied to [data-ln-accordion] by default; re-apply on a custom selector for the look without single-open coordination.
  • Architecture deep-dive: js/ln-accordion/README.md — internals, lifecycle, design rationale.
  • Cross-component principles: docs/architecture/data-flow.md — attributes-as-contract, event-driven coordination, MutationObserver discipline.