I’ll build this as a standalone advanced accordion module for your RX Theme path, with accessibility, keyboard control, nested accordions, URL hash opening, groups, animations, and auto-init support.
Dear friend, place this full code in:
assets/static-js/static-chunks-js/chunk-010-accordion.js
/*!
* RX Theme Advanced Accordion
* File: assets/static-js/static-chunks-js/chunk-010-accordion.js
* Version: 1.0.0
*
* Features:
* - Accessible accordion with ARIA support
* - Single-open or multiple-open mode
* - Smooth height animation
* - Keyboard support
* - Nested accordion support
* - URL hash deep-link opening
* - Auto close on outside click option
* - Open first item option
* - Remember state with localStorage option
* - Lazy content loading hook
* - Custom events for developer extension
* - Works with dynamic AJAX-loaded content
*/
(function () {
"use strict";
const RXAccordion = {
instances: new Map(),
defaults: {
rootSelector: "[data-rx-accordion]",
itemSelector: "[data-rx-accordion-item]",
triggerSelector: "[data-rx-accordion-trigger]",
panelSelector: "[data-rx-accordion-panel]",
activeClass: "is-active",
openingClass: "is-opening",
closingClass: "is-closing",
initializedClass: "rx-accordion-ready",
singleOpen: false,
closeOnOutsideClick: false,
openFirst: false,
allowAllClosed: true,
rememberState: false,
deepLink: true,
animation: true,
animationDuration: 300,
scrollToOpened: false,
scrollOffset: 90,
lazyLoad: false,
storagePrefix: "rxAccordion:",
},
init(options = {}) {
const settings = Object.assign({}, this.defaults, options);
const accordions = document.querySelectorAll(settings.rootSelector);
if (!accordions.length) return;
accordions.forEach((accordion, index) => {
this.setupAccordion(accordion, settings, index);
});
this.bindGlobalEvents(settings);
},
setupAccordion(accordion, settings, index) {
if (accordion.classList.contains(settings.initializedClass)) return;
const accordionId =
accordion.getAttribute("id") ||
`rx-accordion-${Date.now()}-${index}`;
accordion.setAttribute("id", accordionId);
accordion.classList.add(settings.initializedClass);
const localSettings = this.getLocalSettings(accordion, settings);
const items = this.getDirectItems(accordion, localSettings);
accordion.setAttribute("data-rx-accordion-id", accordionId);
if (!items.length) return;
const instance = {
id: accordionId,
element: accordion,
settings: localSettings,
items,
};
this.instances.set(accordionId, instance);
items.forEach((item, itemIndex) => {
this.setupItem(instance, item, itemIndex);
});
this.restoreState(instance);
this.handleInitialOpen(instance);
this.dispatch(accordion, "rxAccordionReady", {
accordion,
id: accordionId,
items,
});
},
getLocalSettings(accordion, settings) {
const local = Object.assign({}, settings);
if (accordion.hasAttribute("data-rx-single")) {
local.singleOpen = accordion.getAttribute("data-rx-single") !== "false";
}
if (accordion.hasAttribute("data-rx-multiple")) {
local.singleOpen = accordion.getAttribute("data-rx-multiple") === "false";
}
if (accordion.hasAttribute("data-rx-open-first")) {
local.openFirst = accordion.getAttribute("data-rx-open-first") !== "false";
}
if (accordion.hasAttribute("data-rx-remember")) {
local.rememberState = accordion.getAttribute("data-rx-remember") !== "false";
}
if (accordion.hasAttribute("data-rx-deeplink")) {
local.deepLink = accordion.getAttribute("data-rx-deeplink") !== "false";
}
if (accordion.hasAttribute("data-rx-animation")) {
local.animation = accordion.getAttribute("data-rx-animation") !== "false";
}
if (accordion.hasAttribute("data-rx-duration")) {
const duration = parseInt(accordion.getAttribute("data-rx-duration"), 10);
if (!Number.isNaN(duration)) {
local.animationDuration = duration;
}
}
if (accordion.hasAttribute("data-rx-scroll")) {
local.scrollToOpened = accordion.getAttribute("data-rx-scroll") !== "false";
}
if (accordion.hasAttribute("data-rx-outside-close")) {
local.closeOnOutsideClick =
accordion.getAttribute("data-rx-outside-close") !== "false";
}
if (accordion.hasAttribute("data-rx-lazy")) {
local.lazyLoad = accordion.getAttribute("data-rx-lazy") !== "false";
}
return local;
},
getDirectItems(accordion, settings) {
return Array.from(accordion.querySelectorAll(settings.itemSelector)).filter(
(item) => item.closest(settings.rootSelector) === accordion
);
},
setupItem(instance, item, index) {
const { settings, id } = instance;
const trigger = item.querySelector(settings.triggerSelector);
const panel = item.querySelector(settings.panelSelector);
if (!trigger || !panel) return;
const itemId =
item.getAttribute("id") ||
`${id}-item-${index + 1}`;
const triggerId =
trigger.getAttribute("id") ||
`${itemId}-trigger`;
const panelId =
panel.getAttribute("id") ||
`${itemId}-panel`;
item.setAttribute("id", itemId);
trigger.setAttribute("id", triggerId);
panel.setAttribute("id", panelId);
trigger.setAttribute("type", "button");
trigger.setAttribute("aria-controls", panelId);
panel.setAttribute("role", "region");
panel.setAttribute("aria-labelledby", triggerId);
const isOpen =
item.classList.contains(settings.activeClass) ||
item.hasAttribute("data-rx-open") ||
trigger.getAttribute("aria-expanded") === "true";
this.setState(instance, item, isOpen, false);
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.toggle(instance, item, true);
});
trigger.addEventListener("keydown", (event) => {
this.handleKeyboard(event, instance, item);
});
},
toggle(instance, item, userAction = false) {
if (this.isOpen(instance, item)) {
this.close(instance, item, userAction);
} else {
this.open(instance, item, userAction);
}
},
open(instance, item, userAction = false) {
const { settings, element } = instance;
if (this.isOpen(instance, item)) return;
if (settings.singleOpen) {
instance.items.forEach((otherItem) => {
if (otherItem !== item) {
this.close(instance, otherItem, false);
}
});
}
this.setState(instance, item, true, settings.animation);
this.lazyLoadPanel(instance, item);
if (settings.rememberState) {
this.saveState(instance);
}
if (settings.scrollToOpened && userAction) {
this.scrollToItem(item, settings.scrollOffset);
}
this.dispatch(element, "rxAccordionOpen", {
accordion: element,
item,
trigger: item.querySelector(settings.triggerSelector),
panel: item.querySelector(settings.panelSelector),
});
},
close(instance, item, userAction = false) {
const { settings, element } = instance;
if (!this.isOpen(instance, item)) return;
if (!settings.allowAllClosed) {
const openItems = instance.items.filter((i) => this.isOpen(instance, i));
if (openItems.length <= 1) return;
}
this.setState(instance, item, false, settings.animation);
if (settings.rememberState) {
this.saveState(instance);
}
this.dispatch(element, "rxAccordionClose", {
accordion: element,
item,
trigger: item.querySelector(settings.triggerSelector),
panel: item.querySelector(settings.panelSelector),
userAction,
});
},
setState(instance, item, open, animate = true) {
const { settings } = instance;
const trigger = item.querySelector(settings.triggerSelector);
const panel = item.querySelector(settings.panelSelector);
if (!trigger || !panel) return;
trigger.setAttribute("aria-expanded", open ? "true" : "false");
item.setAttribute("data-rx-state", open ? "open" : "closed");
if (open) {
item.classList.add(settings.activeClass);
panel.hidden = false;
} else {
item.classList.remove(settings.activeClass);
}
if (!settings.animation || !animate) {
panel.style.height = "";
panel.style.overflow = "";
panel.style.transition = "";
panel.hidden = !open;
return;
}
this.animatePanel(item, panel, open, settings);
},
animatePanel(item, panel, open, settings) {
panel.style.overflow = "hidden";
panel.style.transition = `height ${settings.animationDuration}ms ease`;
if (open) {
item.classList.add(settings.openingClass);
item.classList.remove(settings.closingClass);
panel.hidden = false;
panel.style.height = "0px";
requestAnimationFrame(() => {
panel.style.height = `${panel.scrollHeight}px`;
});
window.setTimeout(() => {
panel.style.height = "";
panel.style.overflow = "";
panel.style.transition = "";
item.classList.remove(settings.openingClass);
}, settings.animationDuration);
} else {
item.classList.add(settings.closingClass);
item.classList.remove(settings.openingClass);
panel.style.height = `${panel.scrollHeight}px`;
requestAnimationFrame(() => {
panel.style.height = "0px";
});
window.setTimeout(() => {
panel.hidden = true;
panel.style.height = "";
panel.style.overflow = "";
panel.style.transition = "";
item.classList.remove(settings.closingClass);
}, settings.animationDuration);
}
},
isOpen(instance, item) {
const trigger = item.querySelector(instance.settings.triggerSelector);
return trigger && trigger.getAttribute("aria-expanded") === "true";
},
handleKeyboard(event, instance, currentItem) {
const { settings } = instance;
const triggers = instance.items
.map((item) => item.querySelector(settings.triggerSelector))
.filter(Boolean);
const currentTrigger = currentItem.querySelector(settings.triggerSelector);
const currentIndex = triggers.indexOf(currentTrigger);
if (currentIndex === -1) return;
let nextIndex = null;
switch (event.key) {
case "ArrowDown":
nextIndex = currentIndex + 1;
if (nextIndex >= triggers.length) nextIndex = 0;
break;
case "ArrowUp":
nextIndex = currentIndex - 1;
if (nextIndex < 0) nextIndex = triggers.length - 1;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = triggers.length - 1;
break;
case "Enter":
case " ":
event.preventDefault();
this.toggle(instance, currentItem, true);
return;
case "Escape":
if (this.isOpen(instance, currentItem)) {
event.preventDefault();
this.close(instance, currentItem, true);
}
return;
default:
return;
}
if (nextIndex !== null) {
event.preventDefault();
triggers[nextIndex].focus();
}
},
handleInitialOpen(instance) {
const { settings } = instance;
if (settings.deepLink && window.location.hash) {
const hashTarget = instance.element.querySelector(window.location.hash);
if (hashTarget) {
const hashItem = hashTarget.matches(settings.itemSelector)
? hashTarget
: hashTarget.closest(settings.itemSelector);
if (hashItem && instance.items.includes(hashItem)) {
this.open(instance, hashItem, false);
this.scrollToItem(hashItem, settings.scrollOffset);
return;
}
}
}
const hasOpenItem = instance.items.some((item) => this.isOpen(instance, item));
if (!hasOpenItem && settings.openFirst && instance.items[0]) {
this.open(instance, instance.items[0], false);
}
},
saveState(instance) {
try {
const { settings, id, items } = instance;
const openIds = items
.filter((item) => this.isOpen(instance, item))
.map((item) => item.getAttribute("id"));
localStorage.setItem(
`${settings.storagePrefix}${id}`,
JSON.stringify(openIds)
);
} catch (error) {
this.debug("Unable to save accordion state", error);
}
},
restoreState(instance) {
try {
const { settings, id, items } = instance;
if (!settings.rememberState) return;
const saved = localStorage.getItem(`${settings.storagePrefix}${id}`);
if (!saved) return;
const openIds = JSON.parse(saved);
if (!Array.isArray(openIds)) return;
items.forEach((item) => {
const shouldOpen = openIds.includes(item.getAttribute("id"));
this.setState(instance, item, shouldOpen, false);
});
} catch (error) {
this.debug("Unable to restore accordion state", error);
}
},
lazyLoadPanel(instance, item) {
const { settings, element } = instance;
if (!settings.lazyLoad) return;
const panel = item.querySelector(settings.panelSelector);
if (!panel || panel.getAttribute("data-rx-loaded") === "true") return;
const url = panel.getAttribute("data-rx-lazy-url");
if (!url) {
panel.setAttribute("data-rx-loaded", "true");
return;
}
panel.setAttribute("data-rx-loading", "true");
fetch(url, {
method: "GET",
credentials: "same-origin",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`Accordion lazy load failed: ${response.status}`);
}
return response.text();
})
.then((html) => {
panel.innerHTML = html;
panel.setAttribute("data-rx-loaded", "true");
panel.removeAttribute("data-rx-loading");
this.dispatch(element, "rxAccordionLazyLoaded", {
accordion: element,
item,
panel,
html,
});
})
.catch((error) => {
panel.removeAttribute("data-rx-loading");
panel.setAttribute("data-rx-load-error", "true");
this.dispatch(element, "rxAccordionLazyError", {
accordion: element,
item,
panel,
error,
});
this.debug(error);
});
},
scrollToItem(item, offset = 90) {
const rect = item.getBoundingClientRect();
const top = rect.top + window.pageYOffset - offset;
window.scrollTo({
top,
behavior: "smooth",
});
},
closeAll(accordionOrId) {
const instance = this.getInstance(accordionOrId);
if (!instance) return;
instance.items.forEach((item) => {
this.close(instance, item, false);
});
},
openAll(accordionOrId) {
const instance = this.getInstance(accordionOrId);
if (!instance) return;
instance.items.forEach((item) => {
this.open(instance, item, false);
});
},
destroy(accordionOrId) {
const instance = this.getInstance(accordionOrId);
if (!instance) return;
const { element, settings, items } = instance;
items.forEach((item) => {
const trigger = item.querySelector(settings.triggerSelector);
const panel = item.querySelector(settings.panelSelector);
if (trigger) {
trigger.removeAttribute("aria-expanded");
trigger.removeAttribute("aria-controls");
}
if (panel) {
panel.removeAttribute("role");
panel.removeAttribute("aria-labelledby");
panel.hidden = false;
panel.style.height = "";
panel.style.overflow = "";
panel.style.transition = "";
}
item.classList.remove(
settings.activeClass,
settings.openingClass,
settings.closingClass
);
item.removeAttribute("data-rx-state");
});
element.classList.remove(settings.initializedClass);
this.instances.delete(instance.id);
},
refresh(root = document) {
this.init({
rootSelector: "[data-rx-accordion]",
});
const accordions = root.querySelectorAll("[data-rx-accordion]");
accordions.forEach((accordion) => {
const id = accordion.getAttribute("data-rx-accordion-id");
const instance = this.instances.get(id);
if (!instance) return;
instance.items = this.getDirectItems(accordion, instance.settings);
instance.items.forEach((item, index) => {
this.setupItem(instance, item, index);
});
});
},
getInstance(accordionOrId) {
if (typeof accordionOrId === "string") {
return this.instances.get(accordionOrId) || null;
}
if (accordionOrId instanceof HTMLElement) {
const id = accordionOrId.getAttribute("data-rx-accordion-id");
return this.instances.get(id) || null;
}
return null;
},
bindGlobalEvents(settings) {
if (this._globalEventsBound) return;
this._globalEventsBound = true;
document.addEventListener("click", (event) => {
this.instances.forEach((instance) => {
if (!instance.settings.closeOnOutsideClick) return;
const clickedInside = instance.element.contains(event.target);
if (!clickedInside) {
instance.items.forEach((item) => {
this.close(instance, item, true);
});
}
});
});
window.addEventListener("hashchange", () => {
this.instances.forEach((instance) => {
if (!instance.settings.deepLink) return;
const target = instance.element.querySelector(window.location.hash);
if (!target) return;
const item = target.matches(instance.settings.itemSelector)
? target
: target.closest(instance.settings.itemSelector);
if (item && instance.items.includes(item)) {
this.open(instance, item, true);
}
});
});
document.addEventListener("rxAjaxContentLoaded", (event) => {
const root = event.detail && event.detail.root ? event.detail.root : document;
this.refresh(root);
});
},
dispatch(element, eventName, detail = {}) {
element.dispatchEvent(
new CustomEvent(eventName, {
detail,
bubbles: true,
})
);
},
debug(...args) {
if (window.RX_DEBUG === true) {
console.warn("[RX Accordion]", ...args);
}
},
};
window.RXAccordion = RXAccordion;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => RXAccordion.init());
} else {
RXAccordion.init();
}
})();
Use this HTML structure:
<div
class="rx-accordion"
data-rx-accordion
data-rx-single="true"
data-rx-open-first="false"
data-rx-remember="true"
data-rx-deeplink="true"
data-rx-animation="true"
data-rx-duration="300"
>
<div class="rx-accordion__item" data-rx-accordion-item id="faq-back-pain">
<button class="rx-accordion__trigger" data-rx-accordion-trigger>
What causes back pain?
<span class="rx-accordion__icon" aria-hidden="true"></span>
</button>
<div class="rx-accordion__panel" data-rx-accordion-panel>
<div class="rx-accordion__content">
Back pain may come from muscle strain, disc problems, arthritis, poor posture, or nerve compression.
</div>
</div>
</div>
<div class="rx-accordion__item" data-rx-accordion-item id="faq-neck-pain">
<button class="rx-accordion__trigger" data-rx-accordion-trigger>
What causes neck pain?
<span class="rx-accordion__icon" aria-hidden="true"></span>
</button>
<div class="rx-accordion__panel" data-rx-accordion-panel>
<div class="rx-accordion__content">
Neck pain may come from posture, muscle spasm, cervical disc disease, injury, or arthritis.
</div>
</div>
</div>
</div>
Add this CSS in your accordion CSS file:
.rx-accordion {
width: 100%;
}
.rx-accordion__item {
border-bottom: 1px solid var(--rx-border-color, #e5e7eb);
}
.rx-accordion__trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0;
border: 0;
background: transparent;
color: var(--rx-text-color, #111827);
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.rx-accordion__trigger:focus-visible {
outline: 2px solid var(--rx-primary-color, #2563eb);
outline-offset: 3px;
}
.rx-accordion__panel[hidden] {
display: none;
}
.rx-accordion__content {
padding: 0 0 1rem;
color: var(--rx-muted-color, #4b5563);
line-height: 1.7;
}
.rx-accordion__icon {
width: 1rem;
height: 1rem;
position: relative;
flex: 0 0 auto;
}
.rx-accordion__icon::before,
.rx-accordion__icon::after {
content: "";
position: absolute;
background: currentColor;
transition: transform 0.25s ease;
}
.rx-accordion__icon::before {
top: 50%;
left: 0;
width: 100%;
height: 2px;
transform: translateY(-50%);
}
.rx-accordion__icon::after {
left: 50%;
top: 0;
width: 2px;
height: 100%;
transform: translateX(-50%);
}
.rx-accordion__item.is-active .rx-accordion__icon::after {
transform: translateX(-50%) rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.rx-accordion__panel,
.rx-accordion__icon::before,
.rx-accordion__icon::after {
transition: none !important;
}
}
Then enqueue it in functions.php:
function rx_theme_enqueue_accordion_script() {
wp_enqueue_script(
'rx-accordion',
get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-010-accordion.js',
array(),
'1.0.0',
true
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_accordion_script' );
Best attributes for your RX Theme:
data-rx-single="true"
Means only one item opens at a time.
data-rx-remember="true"
Means browser remembers which accordion item was opened.
data-rx-deeplink="true"
Means this URL can open a specific item:
https://rxharun.com/example-page/#faq-back-pain
This file is safe as a static chunk file and good for advanced RX Theme frontend structure.