Accordion

Collapsible content sections for organized information.

  • 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 as alwaysOpen: 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.