JavaScript Accordion

@lemonadejs/accordion · ✓ 9 contract checks · framework-agnostic · zero dependencies

<Accordion /> — expansion panels on the v6 contract model.

Panels come from a data array ({ title, content?, disabled? }):

  • content is trusted TEXT (v6 strings are always text, never HTML); rich panel bodies come from the render prop: (item, index) => html view
  • every panel body is created once and KEPT ALIVE across toggles — the collapse is a grid-template-rows (0fr->1fr) transition driven by data-open (CSS), never an unmount, so content state (inputs, nested components) survives open/close cycles; closed bodies are inert (out of the Tab order and the accessibility tree)
  • exclusive by default (a controlled accordion group): bind is the expanded INDEX, -1/null = all closed, opening one closes the other
  • multiple: bind becomes an ARRAY of open indices — each panel toggles independently

Headers are real <button>s: native Enter/Space toggling, native disabled semantics; ArrowUp/ArrowDown walk focus between enabled headers. Each body is a labelled ARIA region (header aria-controls ⇄ body aria-labelledby); panels are keyed by item identity so kept-alive bodies move with their item when the options array changes.

Bound state semantics (the v6 protocol): expanded.set() on user toggles fires onchange(expanded, previous); external writes through the bound state stay silent.

Example

live
import { html } from 'lemonadejs';
import Accordion from '@lemonadejs/accordion';

const App = (props, { state }) => {
    const expanded = state(0);
    const faq = [
        { title: 'How long does shipping take?', content: 'Orders ship within 24 hours and arrive in 2 to 5 business days.' },
        { title: 'Can I return an item?', content: 'Yes — returns are free within 30 days of delivery.' },
        { title: 'Do you ship internationally?', content: 'We ship to 40+ countries; duties are calculated at checkout.' },
        { title: 'Wholesale pricing', content: 'Available on request.', disabled: true },
    ];

    return html`<div>
        <${Accordion} options="${faq}" bind="${expanded}" />
        <p>Open panel: <b>${() => (expanded.value >= 0 && expanded.value !== null ? faq[expanded.value].title : 'none')}</b></p>
    </div>`;
};

Installation

npm install @lemonadejs/accordion
import Accordion from '@lemonadejs/accordion';
import '@lemonadejs/accordion/style.css';

Three deployment forms, one component:

html`<${Accordion} />`                       // by value (no registration)
setComponents({ Accordion });               // then <Accordion /> by name anywhere
createWebComponent(Accordion);              // <lm-accordion> in plain HTML/any framework

Props

Every declared prop arrives as a live state — pass a value for a snapshot or a state for a two-way live wire. Attribute strings are coerced to the declared type.

PropTypeDefaultDescription
bindnumberTwo-way bound value. .set() fires onchange; plain assignment is silent.
optionsarray
renderfunction
multiplebooleanfalse

Events

All event names are lowercase (the platform convention — LJS-305 warns otherwise).

  • onchange

Styling

All classes follow the lm-accordion-* convention; visual variants are data-* attributes on the root. Override freely — there is no styling engine to fight.

Contract

The machine-readable schema ships with the package:

import contract from '@lemonadejs/accordion/contract.json';

verify.json carries the conformance proof produced by verify(Accordion).

Looking for the v5 plugin? See the archived v5 documentation.