Accordion
Collapsible content sections for organized information.
Description
Description
Description
Optional icon: place an element with data-accordion-icon inside the trigger – it will rotate 180° when the panel opens.
Usage
Include the JavaScript code in your project:
/**
* Kaida Accordion component for Tailwind v4.
* Manages collapsible panels with triggers and optional icons.
*
* @class
*/
class Accordion {
/**
* @param {HTMLElement} container - The root element containing all accordion panels.
* @param {Array<{id: string, trigger: HTMLElement, panel: HTMLElement, icon?: HTMLElement, active: boolean}>} items - Configuration objects for each panel.
* @param {Object} [options] - Customization options.
* @param {boolean} [options.alwaysOpen=false] - Whether multiple panels can be open at the same time.
* @param {string} [options.activeClasses="active"] - Space-separated CSS classes applied to an active trigger.
* @param {string} [options.inactiveClasses="inactive"] - Space-separated CSS classes applied to an inactive trigger.
* @param {Function} [options.onOpen] - Callback fired after a panel opens. Receives (accordionInstance, item).
* @param {Function} [options.onClose] - Callback fired after a panel closes. Receives (accordionInstance, item).
* @param {Function} [options.onToggle] - Callback fired after any toggle (open or close). Receives (accordionInstance, item).
*/
constructor(container, items, options = {}) {
/** @private */
this.container = container;
/** @private */
this._containerEl = container; // Keep reference for cleanup
/** @private */
this.items = items;
/**
* Map of bound toggle handlers for easy removal.
* @private
* @type {Map<string, Function>}
*/
this._boundToggle = new Map();
/** @private */
this.options = {
alwaysOpen: false,
activeClasses: "active",
inactiveClasses: "inactive",
onOpen: () => {},
onClose: () => {},
onToggle: () => {},
...options,
};
/** @private Flag to suppress callbacks during initial setup */
this._initializing = true;
this.init();
this._initializing = false;
}
/**
* Initialize the accordion: set the correct initial state and attach event listeners.
* @private
*/
init() {
if (!this.items?.length) return;
// Determine which items should be active after initialization.
// If alwaysOpen is false, only the last active item remains active.
const activeItems = this.items.filter((item) => item.active);
if (!this.options.alwaysOpen && activeItems.length > 1) {
const lastActive = activeItems[activeItems.length - 1];
this.items.forEach((item) => {
if (item.active && item !== lastActive) item.active = false;
});
}
// Apply silent open/close to reach the final visual state without firing callbacks.
this.items.forEach((item) => {
if (item.active) {
this._open(item.id, true);
} else {
this._close(item.id, true);
}
});
// Bind click events and store references for later removal.
this.items.forEach((item) => {
const boundToggle = () => this.toggle(item.id);
this._boundToggle.set(item.id, boundToggle);
item.trigger.addEventListener("click", boundToggle);
});
}
/**
* Parse a space-separated string of CSS classes into an array, filtering out empties.
* @private
* @param {string} classString - Raw class string.
* @returns {string[]} Array of valid class names.
*/
_parseClasses(classString) {
return classString.split(/\s+/).filter(Boolean);
}
/**
* Internal open logic. Can run silently (without user callbacks).
* @private
* @param {string} id - ID of the panel.
* @param {boolean} [silent=false] - If true, onOpen callback is skipped.
*/
_open(id, silent = false) {
const item = this.getItem(id);
if (!item || !item.panel) return;
// Close other panels if alwaysOpen is disabled
if (!this.options.alwaysOpen) {
this.items.forEach((i) => {
if (i !== item) this._close(i.id, true);
});
}
const activeClasses = this._parseClasses(this.options.activeClasses);
const inactiveClasses = this._parseClasses(this.options.inactiveClasses);
item.trigger.classList.add(...activeClasses);
item.trigger.classList.remove(...inactiveClasses);
item.trigger.setAttribute("aria-expanded", "true");
// Tailwind v4 utility: hidden
item.panel.classList.remove("hidden");
item.active = true;
// Tailwind v4 icon rotation (removed when open)
if (item.icon) item.icon.classList.remove("rotate-180");
if (!silent) this.options.onOpen(this, item);
}
/**
* Internal close logic.
* @private
* @param {string} id - ID of the panel.
* @param {boolean} [silent=false] - If true, onClose callback is skipped.
*/
_close(id, silent = false) {
const item = this.getItem(id);
if (!item || !item.panel) return;
const activeClasses = this._parseClasses(this.options.activeClasses);
const inactiveClasses = this._parseClasses(this.options.inactiveClasses);
item.trigger.classList.remove(...activeClasses);
item.trigger.classList.add(...inactiveClasses);
item.trigger.setAttribute("aria-expanded", "false");
// Tailwind v4 utility: hidden
item.panel.classList.add("hidden");
item.active = false;
// Tailwind v4 icon rotation (added when closed)
if (item.icon) item.icon.classList.add("rotate-180");
if (!silent) this.options.onClose(this, item);
}
/**
* Retrieve an item object by its ID.
* @param {string} id
* @returns {Object|undefined}
*/
getItem(id) {
return this.items.find((i) => i.id === id);
}
/**
* Open a panel. Fires the onOpen callback.
* @param {string} id
*/
open(id) {
this._open(id);
}
/**
* Close a panel. Fires the onClose callback.
* @param {string} id
*/
close(id) {
this._close(id);
}
/**
* Toggle a panel (open if closed, close if open). Fires onToggle callback.
* @param {string} id
*/
toggle(id) {
const item = this.getItem(id);
if (!item) return;
if (item.active) {
this._close(id);
} else {
this._open(id);
}
this.options.onToggle(this, item);
}
/**
* Dynamically add a new panel to the accordion.
* @param {Object} itemConfig
* @param {string} itemConfig.id - Unique panel identifier.
* @param {HTMLElement} itemConfig.trigger - Trigger element.
* @param {HTMLElement} itemConfig.panel - Panel element.
* @param {HTMLElement} [itemConfig.icon] - Optional icon element inside the trigger.
* @param {boolean} [itemConfig.active=false] - Initial active state.
*/
addItem({ id, trigger, panel, icon = null, active = false }) {
if (!trigger || !panel || !id) return;
const newItem = { id, trigger, panel, icon, active };
this.items.push(newItem);
const boundToggle = () => this.toggle(id);
this._boundToggle.set(id, boundToggle);
trigger.addEventListener("click", boundToggle);
if (active) this._open(id, this._initializing);
}
/**
* Remove a panel from the accordion by ID.
* @param {string} id
*/
removeItem(id) {
const index = this.items.findIndex((i) => i.id === id);
if (index === -1) return;
const item = this.items[index];
const boundToggle = this._boundToggle.get(id);
if (boundToggle) {
item.trigger.removeEventListener("click", boundToggle);
this._boundToggle.delete(id);
}
this.items.splice(index, 1);
}
/**
* Update properties of an existing panel. Re-binds events if the trigger is replaced.
* @param {string} id
* @param {Object} changes - Object containing properties to overwrite.
*/
updateItem(id, changes) {
const item = this.getItem(id);
if (!item) return;
Object.assign(item, changes);
// If the trigger element was swapped, rebind the click handler
if (changes.trigger) {
const oldBound = this._boundToggle.get(id);
if (oldBound) item.trigger.removeEventListener("click", oldBound);
const boundToggle = () => this.toggle(id);
this._boundToggle.set(id, boundToggle);
changes.trigger.addEventListener("click", boundToggle);
}
}
/**
* Get a snapshot of all panels' active states (useful for hydration/SSR).
* @returns {Array<{id: string, active: boolean}>}
*/
getState() {
return this.items.map(({ id, active }) => ({ id, active }));
}
/**
* Completely destroy the accordion instance.
* Removes all event listeners, clears references, and cleans up the DOM marker.
*/
destroy() {
this.items.forEach((item) => {
const boundToggle = this._boundToggle.get(item.id);
if (boundToggle) {
item.trigger.removeEventListener("click", boundToggle);
}
});
this._boundToggle.clear();
this.items = null;
this.container = null;
if (this._containerEl) {
this._containerEl.removeAttribute("data-accordion-initialized");
}
}
}
/**
* Initialise all accordion containers found in the document.
* Prevents double initialization by marking containers.
* Instance is stored on the DOM element as `_kaidaAccordion`.
*
* @param {Object} [options] - Global options to merge with each accordion's settings.
*/
const initAccordions = (options = {}) => {
document.querySelectorAll("[data-accordion]").forEach((container) => {
// Prevent multiple initializations
if (container.hasAttribute("data-accordion-initialized")) return;
container.setAttribute("data-accordion-initialized", "");
// Separate attribute for always-open mode
const alwaysOpen = container.hasAttribute("data-accordion-always-open");
const activeClasses =
container.getAttribute("data-active-classes") || "active";
const inactiveClasses =
container.getAttribute("data-inactive-classes") || "inactive";
const items = [];
container
.querySelectorAll("[data-accordion-trigger]")
.forEach((trigger) => {
// Ensure the trigger belongs to this exact accordion (not nested)
if (trigger.closest("[data-accordion]") !== container) return;
const id = trigger.getAttribute("data-accordion-trigger");
const panel = document.querySelector(id);
if (!panel) return; // Skip if target panel doesn't exist
items.push({
id,
trigger,
panel,
icon: trigger.querySelector("[data-accordion-icon]"),
active: trigger.getAttribute("aria-expanded") === "true",
});
});
const instance = new Accordion(container, items, {
alwaysOpen,
activeClasses,
inactiveClasses,
...options,
});
// Store instance for direct programmatic access (e.g., container._kaidaAccordion.destroy())
container._kaidaAccordion = instance;
});
};
// Dual export: supports CommonJS/AMD (bundlers) and direct browser globals
if (typeof exports === "object" && typeof module !== "undefined") {
module.exports = { Accordion, initAccordions };
} else if (typeof define === "function" && define.amd) {
define([], () => ({ Accordion, initAccordions }));
} else {
globalThis.KaidaAccordion = Accordion;
globalThis.initAccordions = initAccordions;
}
Then initialise all accordions on the page:
initAccordions();
You can pass global options directly to the function – see the Params section.
Auto‑initialisation
Add this at the end of your accordion.js file:
document.addEventListener("DOMContentLoaded", () => initAccordions());
Then link the script in your HTML:
<script src="accordion.js"></script>
Methods
After initialisation each accordion container exposes an instance via _kaidaAccordion:
const container = document.querySelector("[data-accordion]");
const accordion = container._kaidaAccordion;
This gives you direct access to all public methods:
accordion.open(id)– open a panel by its ID.accordion.close(id)– close a panel by its ID.accordion.toggle(id)– toggle a panel (open if closed, close if open).accordion.getItem(id)– retrieve an item object by ID.accordion.addItem(config)– add a new panel dynamically. Expects{ id, trigger, panel, icon?, active? }.accordion.removeItem(id)– remove a panel and its event listeners.accordion.updateItem(id, changes)– update properties of an existing panel (e.g. swap the trigger element).accordion.getState()– return an array of{ id, active }objects (useful for SSR/hydration).accordion.destroy()– remove all event listeners, clear references, and clean up the DOM marker. Call this before unmounting in SPAs or when using Turbo/Livewire.
Cleaning up (SPAs, Turbo, Livewire)
In single-page applications, call destroy() on the old instance before re‑initialising:
const container = document.querySelector("[data-accordion]");
if (container._kaidaAccordion) {
container._kaidaAccordion.destroy();
}
initAccordions();
For Turbo navigation re‑initialise on turbo:load, for Livewire on livewire:navigated.
Params
The initAccordions() function accepts an optional configuration object:
initAccordions({
alwaysOpen: false, // Allow multiple panels open at the same time
activeClasses: "active", // CSS classes for active (open) state
inactiveClasses: "inactive", // CSS classes for inactive (closed) state
onOpen: (accordion, item) => {}, // Callback when a panel opens
onClose: (accordion, item) => {}, // Callback when a panel closes
onToggle: (accordion, item) => {}, // Callback after any toggle
});
Data attributes
You can also configure individual accordions via data attributes on the container:
data-accordion="open"– allows multiple panels to be open at once (same asalwaysOpen: true).data-accordion-always-open– alternative boolean attribute for the same option.data-active-classes– space‑separated CSS classes applied to an open trigger (default:"active").data-inactive-classes– classes for a closed trigger (default:"inactive").
These attributes override the global options passed to initAccordions() for that specific accordion.