JavaScript 74 views

Responsive Tabs Component with Vanilla JS

Create a responsive tabbed interface using HTML, CSS, and JavaScript for improved content navigation on web pages.

By TWC Team • Jan 28, 2026

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>
Back to Snippets