I’ll create a strong inc/js/tabs.js for RX Theme that works for normal tabs, nested tabs, URL hash tabs, keyboard accessibility, auto tabs, vertical tabs, AJAX-ready hooks, and WordPress-friendly initialization.
My dear friend, create this file:
rx-theme/inc/js/tabs.js
Then paste this full code.
/*!
* RX Theme Tabs JS
* File: inc/js/tabs.js
* Theme: RX Theme
* Description:
* Advanced accessible tabs system for WordPress theme.
* Supports:
* - Click tabs
* - Keyboard navigation
* - URL hash support
* - Nested tabs
* - Auto-rotating tabs
* - Vertical tabs
* - Mobile accordion mode
* - LocalStorage active tab memory
* - Deep linking
* - Lazy loading content
* - AJAX-ready re-initialization
* - Custom events
*/
(function () {
"use strict";
const RX_TABS = {
selector: ".rx-tabs",
tabSelector: ".rx-tab",
panelSelector: ".rx-tab-panel",
navSelector: ".rx-tabs-nav",
activeClass: "is-active",
disabledClass: "is-disabled",
initializedClass: "rx-tabs-initialized",
hiddenClass: "is-hidden",
mobileClass: "is-mobile-tabs",
accordionActiveClass: "is-accordion-active",
storagePrefix: "rx_theme_active_tab_",
resizeTimer: null,
defaults: {
activeIndex: 0,
remember: true,
hash: true,
keyboard: true,
autoRotate: false,
autoRotateDelay: 5000,
pauseOnHover: true,
mobileAccordion: true,
mobileBreakpoint: 768,
lazy: true,
scrollToTab: false,
scrollOffset: 80,
animation: true,
animationDuration: 250,
closeOtherMobilePanels: true
},
init: function (context) {
const root = context || document;
const tabGroups = root.querySelectorAll(this.selector);
if (!tabGroups.length) {
return;
}
tabGroups.forEach((tabs, index) => {
if (tabs.classList.contains(this.initializedClass)) {
return;
}
this.setupTabs(tabs, index);
});
this.bindGlobalEvents();
},
setupTabs: function (tabs, groupIndex) {
const settings = this.getSettings(tabs);
const nav = tabs.querySelector(this.navSelector);
const tabButtons = Array.from(tabs.querySelectorAll(":scope > .rx-tabs-nav .rx-tab"));
const panels = Array.from(tabs.querySelectorAll(":scope > .rx-tabs-content > .rx-tab-panel"));
if (!nav || !tabButtons.length || !panels.length) {
return;
}
tabs.classList.add(this.initializedClass);
if (!tabs.id) {
tabs.id = "rx-tabs-" + Date.now() + "-" + groupIndex;
}
tabs._rxTabsSettings = settings;
tabs._rxTabsButtons = tabButtons;
tabs._rxTabsPanels = panels;
tabs._rxTabsAutoTimer = null;
tabs._rxTabsPaused = false;
this.setupARIA(tabs, tabButtons, panels);
this.setupInitialState(tabs, tabButtons, panels, settings);
this.bindTabEvents(tabs, tabButtons, panels, settings);
if (settings.keyboard) {
this.bindKeyboardEvents(tabs, tabButtons, panels, settings);
}
if (settings.autoRotate) {
this.startAutoRotate(tabs);
}
if (settings.mobileAccordion) {
this.checkMobileMode(tabs);
}
this.dispatchEvent(tabs, "rxTabsReady", {
tabs: tabs,
buttons: tabButtons,
panels: panels,
settings: settings
});
},
getSettings: function (tabs) {
const data = tabs.dataset || {};
return {
activeIndex: this.toInt(data.activeIndex, this.defaults.activeIndex),
remember: this.toBool(data.remember, this.defaults.remember),
hash: this.toBool(data.hash, this.defaults.hash),
keyboard: this.toBool(data.keyboard, this.defaults.keyboard),
autoRotate: this.toBool(data.autoRotate, this.defaults.autoRotate),
autoRotateDelay: this.toInt(data.autoRotateDelay, this.defaults.autoRotateDelay),
pauseOnHover: this.toBool(data.pauseOnHover, this.defaults.pauseOnHover),
mobileAccordion: this.toBool(data.mobileAccordion, this.defaults.mobileAccordion),
mobileBreakpoint: this.toInt(data.mobileBreakpoint, this.defaults.mobileBreakpoint),
lazy: this.toBool(data.lazy, this.defaults.lazy),
scrollToTab: this.toBool(data.scrollToTab, this.defaults.scrollToTab),
scrollOffset: this.toInt(data.scrollOffset, this.defaults.scrollOffset),
animation: this.toBool(data.animation, this.defaults.animation),
animationDuration: this.toInt(data.animationDuration, this.defaults.animationDuration),
closeOtherMobilePanels: this.toBool(data.closeOtherMobilePanels, this.defaults.closeOtherMobilePanels)
};
},
setupARIA: function (tabs, tabButtons, panels) {
const nav = tabs.querySelector(this.navSelector);
if (nav) {
nav.setAttribute("role", "tablist");
if (tabs.dataset.orientation === "vertical") {
nav.setAttribute("aria-orientation", "vertical");
tabs.classList.add("rx-tabs-vertical");
} else {
nav.setAttribute("aria-orientation", "horizontal");
}
}
tabButtons.forEach((button, index) => {
const panel = panels[index];
if (!button.id) {
button.id = tabs.id + "-tab-" + index;
}
if (panel && !panel.id) {
panel.id = tabs.id + "-panel-" + index;
}
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", "false");
button.setAttribute("tabindex", "-1");
if (panel) {
button.setAttribute("aria-controls", panel.id);
}
if (button.classList.contains(this.disabledClass) || button.disabled) {
button.setAttribute("aria-disabled", "true");
button.setAttribute("tabindex", "-1");
} else {
button.setAttribute("aria-disabled", "false");
}
if (panel) {
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-hidden", "true");
panel.setAttribute("tabindex", "0");
panel.setAttribute("aria-labelledby", button.id);
}
});
},
setupInitialState: function (tabs, tabButtons, panels, settings) {
let activeIndex = settings.activeIndex;
const hashIndex = this.getIndexFromHash(tabButtons, panels);
const storedIndex = this.getStoredIndex(tabs);
if (settings.hash && hashIndex !== null) {
activeIndex = hashIndex;
} else if (settings.remember && storedIndex !== null) {
activeIndex = storedIndex;
}
if (!tabButtons[activeIndex] || this.isDisabled(tabButtons[activeIndex])) {
activeIndex = this.getFirstEnabledIndex(tabButtons);
}
if (activeIndex < 0) {
activeIndex = 0;
}
tabButtons.forEach((button, index) => {
const panel = panels[index];
if (index === activeIndex) {
this.activateButton(button);
this.showPanel(panel, false);
} else {
this.deactivateButton(button);
this.hidePanel(panel, false);
}
});
tabs._rxTabsActiveIndex = activeIndex;
},
bindTabEvents: function (tabs, tabButtons, panels, settings) {
tabButtons.forEach((button, index) => {
button.addEventListener("click", (event) => {
event.preventDefault();
if (this.isDisabled(button)) {
return;
}
this.activateTab(tabs, index, true);
});
button.addEventListener("mouseenter", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.pauseAutoRotate(tabs);
}
});
button.addEventListener("mouseleave", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.resumeAutoRotate(tabs);
}
});
button.addEventListener("focus", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.pauseAutoRotate(tabs);
}
});
button.addEventListener("blur", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.resumeAutoRotate(tabs);
}
});
});
panels.forEach((panel) => {
panel.addEventListener("mouseenter", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.pauseAutoRotate(tabs);
}
});
panel.addEventListener("mouseleave", () => {
if (settings.autoRotate && settings.pauseOnHover) {
this.resumeAutoRotate(tabs);
}
});
});
},
bindKeyboardEvents: function (tabs, tabButtons, panels, settings) {
tabButtons.forEach((button, index) => {
button.addEventListener("keydown", (event) => {
if (this.isDisabled(button)) {
return;
}
const key = event.key;
let newIndex = null;
const isVertical = tabs.classList.contains("rx-tabs-vertical");
if (key === "ArrowRight" && !isVertical) {
newIndex = this.getNextEnabledIndex(tabButtons, index);
}
if (key === "ArrowLeft" && !isVertical) {
newIndex = this.getPreviousEnabledIndex(tabButtons, index);
}
if (key === "ArrowDown" && isVertical) {
newIndex = this.getNextEnabledIndex(tabButtons, index);
}
if (key === "ArrowUp" && isVertical) {
newIndex = this.getPreviousEnabledIndex(tabButtons, index);
}
if (key === "Home") {
newIndex = this.getFirstEnabledIndex(tabButtons);
}
if (key === "End") {
newIndex = this.getLastEnabledIndex(tabButtons);
}
if (key === "Enter" || key === " ") {
event.preventDefault();
this.activateTab(tabs, index, true);
return;
}
if (newIndex !== null && newIndex >= 0) {
event.preventDefault();
tabButtons[newIndex].focus();
this.activateTab(tabs, newIndex, true);
}
});
});
},
activateTab: function (tabs, index, userAction) {
const tabButtons = tabs._rxTabsButtons || [];
const panels = tabs._rxTabsPanels || [];
const settings = tabs._rxTabsSettings || this.defaults;
const button = tabButtons[index];
const panel = panels[index];
if (!button || !panel || this.isDisabled(button)) {
return;
}
const oldIndex = typeof tabs._rxTabsActiveIndex === "number" ? tabs._rxTabsActiveIndex : 0;
if (oldIndex === index) {
if (tabs.classList.contains(this.mobileClass)) {
this.toggleMobilePanel(tabs, index);
}
return;
}
this.dispatchEvent(tabs, "rxTabsBeforeChange", {
tabs: tabs,
oldIndex: oldIndex,
newIndex: index,
oldButton: tabButtons[oldIndex],
newButton: button,
oldPanel: panels[oldIndex],
newPanel: panel
});
tabButtons.forEach((tabButton, tabIndex) => {
if (tabIndex === index) {
this.activateButton(tabButton);
} else {
this.deactivateButton(tabButton);
}
});
panels.forEach((tabPanel, panelIndex) => {
if (panelIndex === index) {
this.showPanel(tabPanel, settings.animation);
} else {
this.hidePanel(tabPanel, settings.animation);
}
});
tabs._rxTabsActiveIndex = index;
if (settings.lazy) {
this.lazyLoadPanel(panel);
}
if (settings.remember) {
this.storeIndex(tabs, index);
}
if (settings.hash && userAction) {
this.updateHash(button, panel);
}
if (settings.scrollToTab && userAction) {
this.scrollToElement(tabs, settings.scrollOffset);
}
this.dispatchEvent(tabs, "rxTabsAfterChange", {
tabs: tabs,
oldIndex: oldIndex,
newIndex: index,
activeButton: button,
activePanel: panel
});
},
activateButton: function (button) {
if (!button) return;
button.classList.add(this.activeClass);
button.setAttribute("aria-selected", "true");
button.setAttribute("tabindex", "0");
},
deactivateButton: function (button) {
if (!button) return;
button.classList.remove(this.activeClass);
button.setAttribute("aria-selected", "false");
if (!this.isDisabled(button)) {
button.setAttribute("tabindex", "-1");
}
},
showPanel: function (panel, animate) {
if (!panel) return;
panel.classList.add(this.activeClass);
panel.classList.remove(this.hiddenClass);
panel.removeAttribute("hidden");
panel.setAttribute("aria-hidden", "false");
if (animate) {
panel.style.display = "block";
panel.style.opacity = "0";
panel.style.transform = "translateY(8px)";
requestAnimationFrame(() => {
panel.style.transition = "opacity 250ms ease, transform 250ms ease";
panel.style.opacity = "1";
panel.style.transform = "translateY(0)";
});
window.setTimeout(() => {
panel.style.transition = "";
panel.style.opacity = "";
panel.style.transform = "";
}, 270);
} else {
panel.style.display = "";
}
},
hidePanel: function (panel, animate) {
if (!panel) return;
panel.classList.remove(this.activeClass);
panel.classList.add(this.hiddenClass);
panel.setAttribute("aria-hidden", "true");
panel.setAttribute("hidden", "hidden");
if (animate) {
panel.style.display = "none";
} else {
panel.style.display = "";
}
},
toggleMobilePanel: function (tabs, index) {
const panels = tabs._rxTabsPanels || [];
const tabButtons = tabs._rxTabsButtons || [];
const settings = tabs._rxTabsSettings || this.defaults;
const panel = panels[index];
const button = tabButtons[index];
if (!panel || !button) return;
const isOpen = panel.classList.contains(this.accordionActiveClass);
if (settings.closeOtherMobilePanels) {
panels.forEach((p, i) => {
p.classList.remove(this.accordionActiveClass);
if (tabButtons[i]) {
tabButtons[i].setAttribute("aria-expanded", "false");
}
});
}
if (!isOpen) {
panel.classList.add(this.accordionActiveClass);
button.setAttribute("aria-expanded", "true");
} else {
panel.classList.remove(this.accordionActiveClass);
button.setAttribute("aria-expanded", "false");
}
},
checkMobileMode: function (tabs) {
const settings = tabs._rxTabsSettings || this.defaults;
const tabButtons = tabs._rxTabsButtons || [];
if (window.innerWidth <= settings.mobileBreakpoint) {
tabs.classList.add(this.mobileClass);
tabButtons.forEach((button) => {
button.setAttribute("aria-expanded", button.classList.contains(this.activeClass) ? "true" : "false");
});
} else {
tabs.classList.remove(this.mobileClass);
tabButtons.forEach((button) => {
button.removeAttribute("aria-expanded");
});
}
},
startAutoRotate: function (tabs) {
const settings = tabs._rxTabsSettings || this.defaults;
this.stopAutoRotate(tabs);
tabs._rxTabsAutoTimer = window.setInterval(() => {
if (tabs._rxTabsPaused) {
return;
}
const tabButtons = tabs._rxTabsButtons || [];
const currentIndex = tabs._rxTabsActiveIndex || 0;
const nextIndex = this.getNextEnabledIndex(tabButtons, currentIndex);
if (nextIndex !== null && nextIndex >= 0) {
this.activateTab(tabs, nextIndex, false);
}
}, settings.autoRotateDelay);
},
stopAutoRotate: function (tabs) {
if (tabs._rxTabsAutoTimer) {
window.clearInterval(tabs._rxTabsAutoTimer);
tabs._rxTabsAutoTimer = null;
}
},
pauseAutoRotate: function (tabs) {
tabs._rxTabsPaused = true;
},
resumeAutoRotate: function (tabs) {
tabs._rxTabsPaused = false;
},
lazyLoadPanel: function (panel) {
if (!panel || panel.dataset.loaded === "true") {
return;
}
const lazyImages = panel.querySelectorAll("img[data-src]");
const lazyIframes = panel.querySelectorAll("iframe[data-src]");
const lazyBackgrounds = panel.querySelectorAll("[data-bg]");
lazyImages.forEach((img) => {
img.src = img.dataset.src;
img.removeAttribute("data-src");
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
img.removeAttribute("data-srcset");
}
});
lazyIframes.forEach((iframe) => {
iframe.src = iframe.dataset.src;
iframe.removeAttribute("data-src");
});
lazyBackgrounds.forEach((element) => {
element.style.backgroundImage = "url(" + element.dataset.bg + ")";
element.removeAttribute("data-bg");
});
panel.dataset.loaded = "true";
},
getIndexFromHash: function (tabButtons, panels) {
const hash = window.location.hash;
if (!hash) {
return null;
}
const cleanHash = hash.replace("#", "");
for (let i = 0; i < tabButtons.length; i++) {
const button = tabButtons[i];
const panel = panels[i];
if (!button || !panel) continue;
if (
button.id === cleanHash ||
panel.id === cleanHash ||
button.dataset.tab === cleanHash ||
panel.dataset.tabPanel === cleanHash
) {
return i;
}
}
return null;
},
updateHash: function (button, panel) {
const target = button.dataset.hash || panel.id || button.id;
if (!target) {
return;
}
if (history.pushState) {
history.pushState(null, "", "#" + target);
} else {
window.location.hash = target;
}
},
getStoredIndex: function (tabs) {
try {
const key = this.storagePrefix + tabs.id;
const stored = localStorage.getItem(key);
if (stored === null) {
return null;
}
const value = parseInt(stored, 10);
return isNaN(value) ? null : value;
} catch (error) {
return null;
}
},
storeIndex: function (tabs, index) {
try {
const key = this.storagePrefix + tabs.id;
localStorage.setItem(key, String(index));
} catch (error) {
return false;
}
},
isDisabled: function (button) {
if (!button) return true;
return (
button.disabled ||
button.classList.contains(this.disabledClass) ||
button.getAttribute("aria-disabled") === "true"
);
},
getFirstEnabledIndex: function (buttons) {
for (let i = 0; i < buttons.length; i++) {
if (!this.isDisabled(buttons[i])) {
return i;
}
}
return -1;
},
getLastEnabledIndex: function (buttons) {
for (let i = buttons.length - 1; i >= 0; i--) {
if (!this.isDisabled(buttons[i])) {
return i;
}
}
return -1;
},
getNextEnabledIndex: function (buttons, currentIndex) {
if (!buttons.length) {
return -1;
}
let index = currentIndex;
for (let i = 0; i < buttons.length; i++) {
index = (index + 1) % buttons.length;
if (!this.isDisabled(buttons[index])) {
return index;
}
}
return -1;
},
getPreviousEnabledIndex: function (buttons, currentIndex) {
if (!buttons.length) {
return -1;
}
let index = currentIndex;
for (let i = 0; i < buttons.length; i++) {
index = (index - 1 + buttons.length) % buttons.length;
if (!this.isDisabled(buttons[index])) {
return index;
}
}
return -1;
},
scrollToElement: function (element, offset) {
const rect = element.getBoundingClientRect();
const top = rect.top + window.pageYOffset - offset;
window.scrollTo({
top: top,
behavior: "smooth"
});
},
bindGlobalEvents: function () {
if (this._globalEventsBound) {
return;
}
this._globalEventsBound = true;
window.addEventListener("resize", () => {
window.clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => {
document.querySelectorAll(this.selector + "." + this.initializedClass).forEach((tabs) => {
if (tabs._rxTabsSettings && tabs._rxTabsSettings.mobileAccordion) {
this.checkMobileMode(tabs);
}
});
}, 150);
});
window.addEventListener("hashchange", () => {
document.querySelectorAll(this.selector + "." + this.initializedClass).forEach((tabs) => {
const tabButtons = tabs._rxTabsButtons || [];
const panels = tabs._rxTabsPanels || [];
const settings = tabs._rxTabsSettings || this.defaults;
if (!settings.hash) {
return;
}
const index = this.getIndexFromHash(tabButtons, panels);
if (index !== null && index >= 0) {
this.activateTab(tabs, index, false);
}
});
});
document.addEventListener("rxAjaxContentLoaded", (event) => {
this.init(event.detail && event.detail.context ? event.detail.context : document);
});
document.addEventListener("DOMContentLoaded", () => {
this.init(document);
});
},
refresh: function (context) {
const root = context || document;
root.querySelectorAll(this.selector).forEach((tabs) => {
tabs.classList.remove(this.initializedClass);
this.stopAutoRotate(tabs);
});
this.init(root);
},
destroy: function (tabs) {
if (!tabs) {
return;
}
const tabButtons = tabs._rxTabsButtons || [];
const panels = tabs._rxTabsPanels || [];
this.stopAutoRotate(tabs);
tabs.classList.remove(this.initializedClass);
tabs.classList.remove(this.mobileClass);
tabButtons.forEach((button) => {
button.classList.remove(this.activeClass);
button.removeAttribute("role");
button.removeAttribute("aria-selected");
button.removeAttribute("aria-controls");
button.removeAttribute("aria-disabled");
button.removeAttribute("aria-expanded");
button.removeAttribute("tabindex");
});
panels.forEach((panel) => {
panel.classList.remove(this.activeClass);
panel.classList.remove(this.hiddenClass);
panel.classList.remove(this.accordionActiveClass);
panel.removeAttribute("role");
panel.removeAttribute("aria-hidden");
panel.removeAttribute("aria-labelledby");
panel.removeAttribute("tabindex");
panel.removeAttribute("hidden");
panel.style.display = "";
panel.style.opacity = "";
panel.style.transform = "";
panel.style.transition = "";
});
delete tabs._rxTabsSettings;
delete tabs._rxTabsButtons;
delete tabs._rxTabsPanels;
delete tabs._rxTabsAutoTimer;
delete tabs._rxTabsPaused;
delete tabs._rxTabsActiveIndex;
},
toBool: function (value, fallback) {
if (typeof value === "undefined") {
return fallback;
}
if (value === true || value === "true" || value === "1" || value === "yes") {
return true;
}
if (value === false || value === "false" || value === "0" || value === "no") {
return false;
}
return fallback;
},
toInt: function (value, fallback) {
const number = parseInt(value, 10);
return isNaN(number) ? fallback : number;
},
dispatchEvent: function (element, name, detail) {
const event = new CustomEvent(name, {
bubbles: true,
cancelable: true,
detail: detail || {}
});
element.dispatchEvent(event);
}
};
window.RX_TABS = RX_TABS;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
RX_TABS.init(document);
});
} else {
RX_TABS.init(document);
}
})();
Add this in functions.php or your enqueue file:
function rx_theme_enqueue_tabs_script() {
wp_enqueue_script(
'rx-tabs',
get_template_directory_uri() . '/inc/js/tabs.js',
array(),
wp_get_theme()->get( 'Version' ),
true
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_tabs_script' );
Example HTML structure:
<div class="rx-tabs"
data-active-index="0"
data-remember="true"
data-hash="true"
data-keyboard="true"
data-mobile-accordion="true"
data-auto-rotate="false">
<div class="rx-tabs-nav">
<button class="rx-tab" data-hash="overview">Overview</button>
<button class="rx-tab" data-hash="symptoms">Symptoms</button>
<button class="rx-tab" data-hash="treatment">Treatment</button>
</div>
<div class="rx-tabs-content">
<div class="rx-tab-panel" id="overview">
Overview content here.
</div>
<div class="rx-tab-panel" id="symptoms">
Symptoms content here.
</div>
<div class="rx-tab-panel" id="treatment">
Treatment content here.
</div>
</div>
</div>
Important options you can use:
data-active-index="0"
data-remember="true"
data-hash="true"
data-keyboard="true"
data-auto-rotate="true"
data-auto-rotate-delay="5000"
data-pause-on-hover="true"
data-mobile-accordion="true"
data-mobile-breakpoint="768"
data-lazy="true"
data-scroll-to-tab="true"
data-scroll-offset="80"
data-animation="true"
data-animation-duration="250"
data-close-other-mobile-panels="true"
data-orientation="vertical"
This file is advanced and safe for RX Theme. It does not need jQuery. It is pure JavaScript and WordPress friendly.