I’ll build this as a reusable advanced tabs module for your RX Theme: accessible tabs, keyboard control, deep-link hash support, auto-activation/manual mode, nested tabs safety, responsive dropdown mode hooks, lazy content events, and WordPress-friendly initialization.
Dear friend, create/update this file:
assets/static-js/static-chunks-js/chunk-011-tabs.js
Use this advanced RX Theme tabs code:
/*!
* RX Theme - Advanced Tabs Chunk
* File: assets/static-js/static-chunks-js/chunk-011-tabs.js
* Version: 1.0.0
*
* Features:
* - Accessible ARIA tabs
* - Keyboard navigation
* - Click / hover activation support
* - URL hash deep linking
* - Auto activation / manual activation
* - Nested tabs safe
* - Responsive select/dropdown support
* - Lazy panel reveal event
* - LocalStorage active tab memory
* - WordPress AJAX/re-init friendly
*/
(function () {
'use strict';
/**
* Prevent double loading
*/
if (window.RXThemeTabsLoaded) {
return;
}
window.RXThemeTabsLoaded = true;
/**
* Main namespace
*/
window.RXTheme = window.RXTheme || {};
const RXTabs = {
instances: [],
defaults: {
rootSelector: '[data-rx-tabs]',
tabListSelector: '[data-rx-tab-list]',
tabSelector: '[data-rx-tab]',
panelSelector: '[data-rx-tab-panel]',
activeClass: 'is-active',
disabledClass: 'is-disabled',
hiddenClass: 'is-hidden',
initializedClass: 'rx-tabs-initialized',
storagePrefix: 'rx-theme-tabs-active-',
updateHash: false,
readHash: true,
remember: false,
manual: false,
activation: 'click', // click | hover
loop: true,
animate: true,
animationDuration: 250,
debug: false
},
/**
* Initialize all tab groups
*/
init(options = {}) {
const settings = Object.assign({}, this.defaults, options);
const roots = document.querySelectorAll(settings.rootSelector);
roots.forEach((root, index) => {
if (root.classList.contains(settings.initializedClass)) {
return;
}
const instance = new RXTabsInstance(root, settings, index);
instance.init();
this.instances.push(instance);
});
return this.instances;
},
/**
* Reinitialize after AJAX, pagination, or dynamic content load
*/
refresh(context = document) {
const roots = context.querySelectorAll(this.defaults.rootSelector);
roots.forEach((root, index) => {
if (!root.classList.contains(this.defaults.initializedClass)) {
const instance = new RXTabsInstance(root, this.defaults, index);
instance.init();
this.instances.push(instance);
}
});
},
/**
* Destroy all instances
*/
destroyAll() {
this.instances.forEach((instance) => instance.destroy());
this.instances = [];
},
/**
* Get instance by root element
*/
getInstance(root) {
return this.instances.find((instance) => instance.root === root) || null;
}
};
/**
* Single Tabs Instance
*/
class RXTabsInstance {
constructor(root, settings, index) {
this.root = root;
this.settings = Object.assign({}, settings);
this.index = index;
this.id = this.root.getAttribute('id') || `rx-tabs-${index + 1}`;
this.tabList = null;
this.tabs = [];
this.panels = [];
this.select = null;
this.activeIndex = 0;
this.boundEvents = [];
}
init() {
this.applyDataOptions();
this.prepareRoot();
this.collectElements();
if (!this.tabs.length || !this.panels.length) {
this.log('Tabs or panels missing.');
return;
}
this.setupAccessibility();
this.createResponsiveSelect();
this.bindEvents();
this.activateInitialTab();
this.root.classList.add(this.settings.initializedClass);
this.dispatch('rxTabsReady', {
id: this.id,
root: this.root,
tabs: this.tabs,
panels: this.panels
});
}
/**
* Read per-component settings from data attributes
*/
applyDataOptions() {
const dataset = this.root.dataset;
this.settings.updateHash = this.toBoolean(dataset.rxTabsUpdateHash, this.settings.updateHash);
this.settings.readHash = this.toBoolean(dataset.rxTabsReadHash, this.settings.readHash);
this.settings.remember = this.toBoolean(dataset.rxTabsRemember, this.settings.remember);
this.settings.manual = this.toBoolean(dataset.rxTabsManual, this.settings.manual);
this.settings.loop = this.toBoolean(dataset.rxTabsLoop, this.settings.loop);
this.settings.animate = this.toBoolean(dataset.rxTabsAnimate, this.settings.animate);
if (dataset.rxTabsActivation) {
this.settings.activation = dataset.rxTabsActivation;
}
if (dataset.rxTabsDuration) {
const duration = parseInt(dataset.rxTabsDuration, 10);
if (!Number.isNaN(duration)) {
this.settings.animationDuration = duration;
}
}
}
prepareRoot() {
if (!this.root.id) {
this.root.id = this.id;
}
this.root.setAttribute('data-rx-tabs-id', this.id);
}
collectElements() {
this.tabList = this.root.querySelector(this.settings.tabListSelector);
/**
* Nested tabs safety:
* Only select tabs/panels whose closest root is this.root.
*/
this.tabs = Array.from(this.root.querySelectorAll(this.settings.tabSelector)).filter((tab) => {
return tab.closest(this.settings.rootSelector) === this.root;
});
this.panels = Array.from(this.root.querySelectorAll(this.settings.panelSelector)).filter((panel) => {
return panel.closest(this.settings.rootSelector) === this.root;
});
}
setupAccessibility() {
if (this.tabList) {
this.tabList.setAttribute('role', 'tablist');
}
this.tabs.forEach((tab, index) => {
const tabId = tab.id || `${this.id}-tab-${index + 1}`;
const panel = this.getPanelForTab(tab, index);
const panelId = panel ? panel.id || `${this.id}-panel-${index + 1}` : `${this.id}-panel-${index + 1}`;
tab.id = tabId;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
tab.setAttribute('aria-controls', panelId);
tab.setAttribute('data-rx-tab-index', String(index));
if (tab.matches('a[href]')) {
tab.setAttribute('data-rx-original-href', tab.getAttribute('href') || '');
}
if (panel) {
panel.id = panelId;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', tabId);
panel.setAttribute('tabindex', '0');
panel.setAttribute('data-rx-panel-index', String(index));
}
if (this.isDisabled(tab)) {
tab.setAttribute('aria-disabled', 'true');
tab.setAttribute('tabindex', '-1');
}
});
}
bindEvents() {
this.tabs.forEach((tab, index) => {
const clickHandler = (event) => {
this.onTabClick(event, index);
};
const keydownHandler = (event) => {
this.onKeydown(event, index);
};
tab.addEventListener('click', clickHandler);
tab.addEventListener('keydown', keydownHandler);
this.boundEvents.push({
element: tab,
type: 'click',
handler: clickHandler
});
this.boundEvents.push({
element: tab,
type: 'keydown',
handler: keydownHandler
});
if (this.settings.activation === 'hover') {
const mouseenterHandler = () => {
if (!this.isDisabled(tab)) {
this.activate(index, {
focus: false,
updateHash: false,
remember: true,
source: 'hover'
});
}
};
tab.addEventListener('mouseenter', mouseenterHandler);
this.boundEvents.push({
element: tab,
type: 'mouseenter',
handler: mouseenterHandler
});
}
});
const hashHandler = () => {
this.activateFromHash();
};
window.addEventListener('hashchange', hashHandler);
this.boundEvents.push({
element: window,
type: 'hashchange',
handler: hashHandler
});
}
onTabClick(event, index) {
const tab = this.tabs[index];
if (!tab || this.isDisabled(tab)) {
event.preventDefault();
return;
}
event.preventDefault();
this.activate(index, {
focus: true,
updateHash: this.settings.updateHash,
remember: true,
source: 'click'
});
}
onKeydown(event, currentIndex) {
const key = event.key;
let nextIndex = null;
switch (key) {
case 'ArrowRight':
case 'ArrowDown':
nextIndex = this.getNextEnabledIndex(currentIndex);
break;
case 'ArrowLeft':
case 'ArrowUp':
nextIndex = this.getPreviousEnabledIndex(currentIndex);
break;
case 'Home':
nextIndex = this.getFirstEnabledIndex();
break;
case 'End':
nextIndex = this.getLastEnabledIndex();
break;
case 'Enter':
case ' ':
event.preventDefault();
this.activate(currentIndex, {
focus: true,
updateHash: this.settings.updateHash,
remember: true,
source: 'keyboard-confirm'
});
return;
default:
return;
}
if (nextIndex === null || nextIndex === undefined || nextIndex < 0) {
return;
}
event.preventDefault();
if (this.settings.manual) {
this.focusTab(nextIndex);
} else {
this.activate(nextIndex, {
focus: true,
updateHash: this.settings.updateHash,
remember: true,
source: 'keyboard-auto'
});
}
}
activateInitialTab() {
let index = null;
if (this.settings.readHash) {
index = this.getIndexFromHash();
}
if (index === null && this.settings.remember) {
index = this.getRememberedIndex();
}
if (index === null) {
index = this.getIndexFromActiveMarkup();
}
if (index === null) {
index = 0;
}
if (this.isDisabled(this.tabs[index])) {
index = this.getFirstEnabledIndex();
}
this.activate(index, {
focus: false,
updateHash: false,
remember: false,
source: 'init'
});
}
activate(index, options = {}) {
const config = Object.assign({
focus: false,
updateHash: false,
remember: false,
source: 'api'
}, options);
const tab = this.tabs[index];
const panel = this.getPanelForTab(tab, index);
if (!tab || !panel || this.isDisabled(tab)) {
return false;
}
const previousIndex = this.activeIndex;
const previousTab = this.tabs[previousIndex];
const previousPanel = this.getPanelForTab(previousTab, previousIndex);
this.tabs.forEach((item, itemIndex) => {
const itemPanel = this.getPanelForTab(item, itemIndex);
const isActive = itemIndex === index;
item.classList.toggle(this.settings.activeClass, isActive);
item.setAttribute('aria-selected', isActive ? 'true' : 'false');
item.setAttribute('tabindex', isActive ? '0' : '-1');
if (itemPanel) {
itemPanel.classList.toggle(this.settings.activeClass, isActive);
itemPanel.hidden = !isActive;
itemPanel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
if (isActive) {
itemPanel.classList.remove(this.settings.hiddenClass);
} else {
itemPanel.classList.add(this.settings.hiddenClass);
}
}
});
this.activeIndex = index;
if (this.select) {
this.select.value = String(index);
}
if (config.focus) {
this.focusTab(index);
}
if (config.updateHash) {
this.updateHash(tab, panel);
}
if (config.remember && this.settings.remember) {
this.rememberIndex(index);
}
if (this.settings.animate) {
this.animatePanel(panel);
}
this.dispatch('rxTabsChange', {
id: this.id,
index,
previousIndex,
tab,
panel,
previousTab,
previousPanel,
source: config.source
});
/**
* Useful for lazy scripts, sliders, charts, ads, embeds, etc.
*/
this.dispatch('rxTabsPanelVisible', {
id: this.id,
index,
tab,
panel
}, panel);
return true;
}
focusTab(index) {
const tab = this.tabs[index];
if (tab && typeof tab.focus === 'function') {
tab.focus({
preventScroll: true
});
}
}
getPanelForTab(tab, index) {
if (!tab) {
return this.panels[index] || null;
}
const controls = tab.getAttribute('aria-controls');
if (controls) {
const panel = this.root.querySelector(`#${this.escapeCss(controls)}`);
if (panel && panel.closest(this.settings.rootSelector) === this.root) {
return panel;
}
}
const target = tab.getAttribute('data-rx-tab-target');
if (target) {
const panel = this.root.querySelector(target);
if (panel && panel.closest(this.settings.rootSelector) === this.root) {
return panel;
}
}
return this.panels[index] || null;
}
getIndexFromActiveMarkup() {
const activeTab = this.tabs.find((tab) => {
return tab.classList.contains(this.settings.activeClass) ||
tab.getAttribute('aria-selected') === 'true' ||
tab.hasAttribute('data-rx-tab-active');
});
if (!activeTab) {
return null;
}
return this.tabs.indexOf(activeTab);
}
getIndexFromHash() {
const hash = window.location.hash;
if (!hash || hash.length < 2) {
return null;
}
const cleanHash = decodeURIComponent(hash.slice(1));
const panelIndex = this.panels.findIndex((panel) => {
return panel.id === cleanHash;
});
if (panelIndex >= 0) {
return panelIndex;
}
const tabIndex = this.tabs.findIndex((tab) => {
return tab.id === cleanHash;
});
if (tabIndex >= 0) {
return tabIndex;
}
return null;
}
activateFromHash() {
if (!this.settings.readHash) {
return;
}
const index = this.getIndexFromHash();
if (index !== null && index !== this.activeIndex) {
this.activate(index, {
focus: false,
updateHash: false,
remember: true,
source: 'hash'
});
}
}
updateHash(tab, panel) {
const targetId = panel && panel.id ? panel.id : tab.id;
if (!targetId) {
return;
}
const currentScroll = {
x: window.pageXOffset,
y: window.pageYOffset
};
history.replaceState(null, '', `#${encodeURIComponent(targetId)}`);
window.scrollTo(currentScroll.x, currentScroll.y);
}
rememberIndex(index) {
try {
localStorage.setItem(this.getStorageKey(), String(index));
} catch (error) {
this.log('LocalStorage remember failed.', error);
}
}
getRememberedIndex() {
try {
const value = localStorage.getItem(this.getStorageKey());
if (value === null) {
return null;
}
const index = parseInt(value, 10);
if (Number.isNaN(index) || index < 0 || index >= this.tabs.length) {
return null;
}
return index;
} catch (error) {
this.log('LocalStorage read failed.', error);
return null;
}
}
getStorageKey() {
return `${this.settings.storagePrefix}${this.id}`;
}
createResponsiveSelect() {
const shouldCreate = this.toBoolean(this.root.dataset.rxTabsSelect, false);
if (!shouldCreate) {
return;
}
const select = document.createElement('select');
select.className = 'rx-tabs-select';
select.setAttribute('aria-label', this.root.dataset.rxTabsSelectLabel || 'Select tab');
this.tabs.forEach((tab, index) => {
const option = document.createElement('option');
option.value = String(index);
option.textContent = this.getTabText(tab) || `Tab ${index + 1}`;
if (this.isDisabled(tab)) {
option.disabled = true;
}
select.appendChild(option);
});
const wrapper = document.createElement('div');
wrapper.className = 'rx-tabs-select-wrap';
wrapper.appendChild(select);
this.root.insertBefore(wrapper, this.root.firstChild);
const changeHandler = () => {
const index = parseInt(select.value, 10);
if (!Number.isNaN(index)) {
this.activate(index, {
focus: false,
updateHash: this.settings.updateHash,
remember: true,
source: 'select'
});
}
};
select.addEventListener('change', changeHandler);
this.boundEvents.push({
element: select,
type: 'change',
handler: changeHandler
});
this.select = select;
}
animatePanel(panel) {
if (!panel) {
return;
}
panel.style.removeProperty('height');
panel.style.removeProperty('overflow');
panel.style.removeProperty('transition');
const prefersReducedMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
return;
}
panel.style.overflow = 'hidden';
panel.style.height = '0px';
panel.style.transition = `height ${this.settings.animationDuration}ms ease`;
requestAnimationFrame(() => {
panel.style.height = `${panel.scrollHeight}px`;
window.setTimeout(() => {
panel.style.removeProperty('height');
panel.style.removeProperty('overflow');
panel.style.removeProperty('transition');
}, this.settings.animationDuration + 30);
});
}
getNextEnabledIndex(currentIndex) {
let next = currentIndex + 1;
while (next < this.tabs.length) {
if (!this.isDisabled(this.tabs[next])) {
return next;
}
next++;
}
if (this.settings.loop) {
return this.getFirstEnabledIndex();
}
return currentIndex;
}
getPreviousEnabledIndex(currentIndex) {
let previous = currentIndex - 1;
while (previous >= 0) {
if (!this.isDisabled(this.tabs[previous])) {
return previous;
}
previous--;
}
if (this.settings.loop) {
return this.getLastEnabledIndex();
}
return currentIndex;
}
getFirstEnabledIndex() {
return this.tabs.findIndex((tab) => !this.isDisabled(tab));
}
getLastEnabledIndex() {
for (let index = this.tabs.length - 1; index >= 0; index--) {
if (!this.isDisabled(this.tabs[index])) {
return index;
}
}
return 0;
}
isDisabled(tab) {
if (!tab) {
return true;
}
return tab.disabled ||
tab.classList.contains(this.settings.disabledClass) ||
tab.getAttribute('aria-disabled') === 'true' ||
tab.hasAttribute('data-rx-tab-disabled');
}
getTabText(tab) {
return (tab.textContent || '').replace(/\s+/g, ' ').trim();
}
dispatch(name, detail = {}, target = this.root) {
const event = new CustomEvent(name, {
bubbles: true,
cancelable: true,
detail
});
target.dispatchEvent(event);
}
destroy() {
this.boundEvents.forEach((item) => {
item.element.removeEventListener(item.type, item.handler);
});
this.boundEvents = [];
this.tabs.forEach((tab) => {
tab.removeAttribute('role');
tab.removeAttribute('aria-selected');
tab.removeAttribute('tabindex');
tab.removeAttribute('aria-controls');
tab.removeAttribute('data-rx-tab-index');
});
this.panels.forEach((panel) => {
panel.removeAttribute('role');
panel.removeAttribute('aria-labelledby');
panel.removeAttribute('tabindex');
panel.removeAttribute('data-rx-panel-index');
panel.hidden = false;
panel.classList.remove(this.settings.hiddenClass);
});
if (this.select) {
const wrapper = this.select.closest('.rx-tabs-select-wrap');
if (wrapper) {
wrapper.remove();
}
}
this.root.classList.remove(this.settings.initializedClass);
}
toBoolean(value, fallback) {
if (value === undefined || value === null || value === '') {
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;
}
escapeCss(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
log(...args) {
if (this.settings.debug) {
console.log('[RXTabs]', ...args);
}
}
}
/**
* Public API
*/
window.RXTheme.Tabs = RXTabs;
/**
* Auto init on DOM ready
*/
function ready(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback);
} else {
callback();
}
}
ready(() => {
RXTabs.init();
});
/**
* WordPress / AJAX friendly refresh hooks
*/
document.addEventListener('rxThemeAjaxLoaded', function (event) {
RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
});
document.addEventListener('rxThemeContentUpdated', function (event) {
RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
});
document.addEventListener('rxThemeInfiniteLoaded', function (event) {
RXTabs.refresh(event.detail && event.detail.context ? event.detail.context : document);
});
})();
Example HTML structure
Use this in your theme template:
<div
class="rx-tabs"
data-rx-tabs
data-rx-tabs-update-hash="true"
data-rx-tabs-read-hash="true"
data-rx-tabs-remember="true"
data-rx-tabs-select="true"
>
<div class="rx-tabs__nav" data-rx-tab-list>
<button class="rx-tabs__tab" data-rx-tab>Overview</button>
<button class="rx-tabs__tab" data-rx-tab>Symptoms</button>
<button class="rx-tabs__tab" data-rx-tab>Diagnosis</button>
<button class="rx-tabs__tab" data-rx-tab>Treatment</button>
</div>
<div class="rx-tabs__panel" data-rx-tab-panel>
Overview content here.
</div>
<div class="rx-tabs__panel" data-rx-tab-panel>
Symptoms content here.
</div>
<div class="rx-tabs__panel" data-rx-tab-panel>
Diagnosis content here.
</div>
<div class="rx-tabs__panel" data-rx-tab-panel>
Treatment content here.
</div>
</div>
Optional basic CSS
Add this to your main tabs CSS file:
.rx-tabs {
width: 100%;
}
.rx-tabs__nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid var(--rx-border-color, #e5e7eb);
margin-bottom: 16px;
}
.rx-tabs__tab {
appearance: none;
border: 0;
background: transparent;
cursor: pointer;
padding: 10px 14px;
font: inherit;
color: var(--rx-text-muted, #64748b);
border-bottom: 2px solid transparent;
}
.rx-tabs__tab.is-active {
color: var(--rx-primary, #2563eb);
border-bottom-color: var(--rx-primary, #2563eb);
}
.rx-tabs__tab:focus-visible {
outline: 2px solid var(--rx-primary, #2563eb);
outline-offset: 2px;
}
.rx-tabs__tab.is-disabled,
.rx-tabs__tab[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.rx-tabs__panel {
display: block;
}
.rx-tabs__panel.is-hidden,
.rx-tabs__panel[hidden] {
display: none;
}
.rx-tabs-select-wrap {
display: none;
margin-bottom: 16px;
}
.rx-tabs-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--rx-border-color, #e5e7eb);
border-radius: 8px;
background: var(--rx-surface, #ffffff);
color: var(--rx-text, #111827);
}
@media (max-width: 640px) {
.rx-tabs[data-rx-tabs-select="true"] .rx-tabs-select-wrap {
display: block;
}
.rx-tabs[data-rx-tabs-select="true"] .rx-tabs__nav {
display: none;
}
}
Enqueue example in functions.php
function rx_theme_enqueue_tabs_chunk() {
wp_enqueue_script(
'rx-theme-tabs',
get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-011-tabs.js',
array(),
'1.0.0',
true
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_tabs_chunk' );
This file is safe for advanced RX Theme use because it supports accessibility, SEO-friendly content visibility, keyboard navigation, deep links, AJAX refresh, and future dynamic WordPress blocks.