Accordion
@ailuracode/alpine-accordion
Headless accordion store with single or multiple open panels, optional default open items, keyboard focus management, and ARIA helpers.
Install
pnpm add @ailuracode/alpine-accordion @ailuracode/alpine-core alpinejsOpen panel state is backed by an inline lightweight state — no extra dependency.
Quick start
import Alpine from "alpinejs";import { accordionPlugin } from "@ailuracode/alpine-accordion";
Alpine.plugin(accordionPlugin());Alpine.start();Store API
| Method | Description |
|---|---|
register(accordionId, options?) |
Create a group (mode, defaultOpen, onChange) |
registerItem(accordionId, itemId, disabled?) |
Register a panel trigger |
open(accordionId, itemId) / close / toggle |
Panel visibility |
isOpen(accordionId, itemId) |
Whether a panel is expanded |
openIds(accordionId) |
Array of currently open item ids |
activeItem(accordionId) |
Focused trigger id |
handleKeydown(accordionId, event) |
ArrowUp/ArrowDown/Home/End |
triggerProps(accordionId, itemId) |
aria-expanded, aria-controls, tabindex |
panelProps(accordionId, itemId) |
role, aria-labelledby, aria-hidden |
Options
| Option | Default | Description |
|---|---|---|
mode |
'single' |
'single' closes other panels when one opens; 'multiple' allows several open |
defaultOpen |
— | Item id or array of ids open after registerItem |
onChange |
— | (openIds: string[]) => void when open state changes |
storeKey |
'accordion' |
$store key the Alpine plugin registers under |
magicKey |
'accordion' (or storeKey when renamed) |
$accordion magic key the Alpine plugin registers under |
In single mode, only the first id in defaultOpen is used when an array is passed.
Avoiding name collisions
If your application already owns a $store.accordion or another toolkit plugin registers on that name, rename the integration surface without touching the controller:
Alpine.plugin(accordionPlugin({ storeKey: "faq", // → $store.faq // magicKey follows storeKey by default → $faq magicKey: "disclosure", // explicit override → $disclosure}));storeKey is the only argument most hosts need. magicKey moves independently only when both names must be freed. The exposed constants DEFAULT_ACCORDION_STORE_KEY and DEFAULT_ACCORDION_MAGIC_KEY keep the rename discoverable from TypeScript.
Single mode
Only one panel open at a time.
$store.accordion.register("faq", { mode: "single" });["item-1", "item-2"].forEach((id) => $store.accordion.registerItem("faq", id));<div x-data x-init=" $store.accordion.register('faq', { mode: 'single' }); ['item-1','item-2'].forEach(id => $store.accordion.registerItem('faq', id)); " @keydown="$store.accordion.handleKeydown('faq', $event)"> <template x-for="id in ['item-1','item-2']" :key="id"> <div> <button x-bind="$store.accordion.triggerProps('faq', id)" @click="$store.accordion.toggle('faq', id)" x-text="id" ></button> <div class="overflow-hidden" x-show="$store.accordion.isOpen('faq', id)" x-collapse x-bind="$store.accordion.panelProps('faq', id)" > <p class="px-4 py-3">Answer for <span x-text="id"></span></p> </div> </div> </template></div>Multiple mode
Several panels can stay open.
$store.accordion.register("settings", { mode: "multiple" });["notifications", "privacy"].forEach((id) => $store.accordion.registerItem("settings", id));<div x-init=" $store.accordion.register('settings', { mode: 'multiple' }); ['notifications','privacy'].forEach(id => $store.accordion.registerItem('settings', id)); "> <!-- same markup as single mode --></div>Default open
Pass defaultOpen when registering. Panels open automatically when their item is registered.
// Single — one panel open on init$store.accordion.register("faq", { mode: "single", defaultOpen: "item-2",});
// Multiple — several panels open on init$store.accordion.register("settings", { mode: "multiple", defaultOpen: ["notifications", "integrations"],});
["item-1", "item-2", "item-3"].forEach((id) => $store.accordion.registerItem("faq", id));Read open ids reactively:
<span x-text="$store.accordion.openIds('faq').join(', ')"></span>Panel animation
@ailuracode/alpine-accordion is headless — it does not animate panels. Use the official @alpinejs/collapse plugin: x-collapse must sit on the same element as x-show.
pnpm add @alpinejs/collapseimport collapse from "@alpinejs/collapse";
Alpine.plugin(collapse);<div x-show="$store.accordion.isOpen('faq', id)" x-collapse x-bind="$store.accordion.panelProps('faq', id)" class="overflow-hidden"> <div class="px-4 py-3">Panel content</div></div>Put padding on an inner wrapper — vertical padding on the same node as x-collapse prevents height from reaching 0 and the close animation can appear stuck.
Optional modifiers: x-collapse.duration.300ms, x-collapse.min.50px. See the Collapse plugin docs.
panelProps() does not set hidden — visibility is driven by x-show on the client.
SSR
Safe when panels start collapsed; visibility is controlled with x-show on the client. Use defaultOpen only when markup is hydrated on the client.
License
MIT
