Responsive Tabs Component with Vanilla JS
Create a responsive tabbed interface using HTML, CSS, and JavaScript for improved content navigation on web pages.
Code
<!--
Responsive Tabs Component (Vanilla JS)
Accessible, responsive tab interface with keyboard support; auto-initializes all .tabs groups on the page.
-->
<div class="tabs" data-tabs>
<button class="tab" data-tab="tab1" type="button">Tab 1</button>
<button class="tab" data-tab="tab2" type="button">Tab 2</button>
</div>
<div class="tab-content">
<div class="content" id="tab1">Content for Tab 1</div>
<div class="content" id="tab2">Content for Tab 2</div>
</div>
<style>
.tabs { display: flex; gap: 8px; flex-wrap: wrap; }
.tab { flex: 1; padding: 10px 12px; cursor: pointer; border: 1px solid #d0d7de; background: #fff; border-radius: 8px; }
.tab[aria-selected="true"] { background: #0b5fff; color: #fff; border-color: #0b5fff; }
.tab:focus-visible { outline: 3px solid rgba(11,95,255,.35); outline-offset: 2px; }
.content { display: none; padding: 12px; border: 1px solid #d0d7de; border-radius: 8px; margin-top: 10px; }
.content.is-active { display: block; }
@media (max-width: 520px) { .tab { flex: 1 0 100%; } }
</style>
<script>
(() => {
'use strict';
// Initializes a single tabs group: wires events, applies ARIA, and activates default tab.
const initTabs = async (tabsEl) => {
try {
const tabButtons = [...tabsEl.querySelectorAll('.tab[data-tab]')];
if (!tabButtons.length) return;
// Find the nearest tab-content after the tabs container (keeps components modular on a page).
const contentRoot = tabsEl.nextElementSibling?.classList.contains('tab-content')
? tabsEl.nextElementSibling
: null;
if (!contentRoot) throw new Error('Missing adjacent .tab-content container.');
const contents = new Map(
[...contentRoot.querySelectorAll('.content[id]')].map((el) => [el.id, el])
);
const activate = (btn) => {
const targetId = btn?.dataset?.tab;
const panel = targetId ? contents.get(targetId) : null;
if (!panel) return; // Fail-safe if markup is incomplete/mismatched.
// Batch DOM writes for performance: minimal queries and no reflow-heavy operations.
tabButtons.forEach((b, i) => {
const selected = b === btn;
b.classList.toggle('is-active', selected);
b.setAttribute('aria-selected', String(selected));
b.setAttribute('tabindex', selected ? '0' : '-1');
// Link tab to panel and vice versa (accessibility).
const panelId = b.dataset.tab;
b.id ||= `tab-${panelId}-${i}`;
});
contents.forEach((c) => {
const isActive = c === panel;
c.classList.toggle('is-active', isActive);
c.hidden = !isActive;
c.setAttribute('aria-hidden', String(!isActive));
});
};
// ARIA roles and relationships.
tabsEl.setAttribute('role', 'tablist');
tabButtons.forEach((btn, i) => {
const panelId = btn.dataset.tab;
const panel = contents.get(panelId);
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-controls', panelId);
btn.setAttribute('tabindex', '-1');
panel?.setAttribute('role', 'tabpanel');
panel?.setAttribute('aria-labelledby', btn.id || `tab-${panelId}-${i}`);
});
// Event delegation: fewer listeners, better performance for many tabs.
tabsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.tab[data-tab]');
if (btn && tabsEl.contains(btn)) activate(btn);
});
// Keyboard navigation: Arrow keys, Home/End.
tabsEl.addEventListener('keydown', (e) => {
const current = e.target.closest('.tab[data-tab]');
if (!current) return;
const idx = tabButtons.indexOf(current);
const moveTo = (nextIdx) => {
const btn = tabButtons[(nextIdx + tabButtons.length) % tabButtons.length];
activate(btn);
btn.focus();
};
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault(); moveTo(idx + 1); break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault(); moveTo(idx - 1); break;
case 'Home':
e.preventDefault(); moveTo(0); break;
case 'End':
e.preventDefault(); moveTo(tabButtons.length - 1); break;
default:
break;
}
});
// Default activation: prefer .is-active or aria-selected, otherwise first tab.
const defaultTab =
tabButtons.find((b) => b.classList.contains('is-active') || b.getAttribute('aria-selected') === 'true') ||
tabButtons[0];
// Async-friendly init hook (allows future async enhancements; safe no-op today).
await Promise.resolve();
activate(defaultTab);
} catch (err) {
console.error('[Tabs] Initialization failed:', err);
}
};
// Avoid global scope pollution: init on DOM ready.
const boot = () => document.querySelectorAll('[data-tabs]').forEach((el) => initTabs(el));
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', boot, { once: true })
: boot();
// Example usage: Duplicate the .tabs[data-tabs] + adjacent .tab-content block for multiple instances on a page.
})();
</script>