Tab Group

Accessible tabs built from the sections you pass in, with arrow-key navigation and IDs that stay unique when the component appears more than once on a page. Without JavaScript every section stays visible.

Source on GitHub

Usage

Each child of the slot becomes one panel, and its data-tab attribute is the tab's text. label names the tab list for screen readers.

examples/components/tab-group/tab-group.html

$10 per month, cancel any time.

$100 per year, two months free.

$25 per person per month.

html
<tab-group data-bascik-prop-label="Pricing plans">
  <section data-tab="Monthly"><p>$10 per month, cancel any time.</p></section>
  <section data-tab="Yearly"><p>$100 per year, two months free.</p></section>
  <section data-tab="Team"><p>$25 per person per month.</p></section>
</tab-group>
html examples/components/tab-group/tab-group.html
<!-- @bascik
Accessible tabs built from the sections passed in. Each child of the default slot becomes one tab panel, labeled by its data-tab attribute. Without JavaScript every section stays visible, stacked.
@prop label - Accessible name for the tab list, for example "Pricing plans".
@slot default - One element per tab, each with a data-tab="Label" attribute.
-->
<style>
  .tab-group {
    display: block;
  }

  .tablist {
    display: flex;
    gap: 0.25rem;
    border-bottom: 1px solid var(--color-border, color-mix(in srgb, currentColor 30%, transparent));
  }

  .tab {
    padding: 0.5rem 1rem;
    border: 0;
    border-bottom: 3px solid transparent;
    border-radius: var(--radius, 0.5rem) var(--radius, 0.5rem) 0 0;
    background: none;
    color: var(--color-muted, inherit);
    font: inherit;
    cursor: pointer;
  }

  .tab:hover {
    background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
  }

  .tab[aria-selected="true"] {
    border-bottom-color: var(--color-accent, currentColor);
    background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
    color: var(--color-text, inherit);
    font-weight: 700;
  }

  .tab:focus-visible,
  .panel:focus-visible {
    outline: 2px solid var(--color-accent, currentColor);
    outline-offset: 2px;
  }

  .panel {
    padding: 1rem;
    background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
    border-radius: 0 0 var(--radius, 0.5rem) var(--radius, 0.5rem);
  }
</style>
<div class="tab-group" id="root">
  <div class="tablist" id="tablist" role="tablist" data-bascik-attr-aria-label="label" hidden></div>
  <div class="panels" id="panels">
    <div data-bascik-slot></div>
  </div>
</div>
<script>
  // Every id below is rewritten per instance, so several tab groups on one page stay independent.
  const root = document.getElementById('root');
  const tablist = document.getElementById('tablist');
  const panels = [...document.getElementById('panels').children];
  const tabs = [];

  const select = (index, moveFocus) => {
    tabs.forEach((tab, i) => {
      const selected = i === index;
      tab.setAttribute('aria-selected', String(selected));
      tab.tabIndex = selected ? 0 : -1;
      panels[i].hidden = !selected;
    });
    if (moveFocus) tabs[index].focus();
  };

  panels.forEach((panel, index) => {
    // Runtime ids are derived from the root's scoped id, so they are unique per instance.
    const tabId = `${root.id}-tab-${index}`;
    const panelId = `${root.id}-panel-${index}`;
    const tab = document.createElement('button');
    tab.type = 'button';
    // A static class string is rewritten to the scoped name defined in the <style> above.
    tab.className = 'tab';
    tab.textContent = panel.dataset.tab || `Tab ${index + 1}`;
    tab.setAttribute('role', 'tab');
    tab.setAttribute('id', tabId);
    tab.setAttribute('aria-controls', panelId);
    tab.addEventListener('click', () => select(index, false));
    tablist.append(tab);
    tabs.push(tab);

    panel.classList.add('panel');
    panel.setAttribute('id', panelId);
    panel.setAttribute('role', 'tabpanel');
    panel.setAttribute('aria-labelledby', tabId);
    panel.tabIndex = 0;
  });

  tablist.addEventListener('keydown', (event) => {
    const current = tabs.indexOf(document.activeElement);
    if (current === -1) return;
    const last = tabs.length - 1;
    const next = {
      ArrowRight: current === last ? 0 : current + 1,
      ArrowLeft: current === 0 ? last : current - 1,
      Home: 0,
      End: last,
    }[event.key];
    if (next === undefined) return;
    event.preventDefault();
    select(next, true);
  });

  if (tabs.length > 0) {
    tablist.hidden = false;
    select(0, false);
  }
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.

How It Works

  • The script builds one <button role="tab"> per section and gives every tab and panel an ID derived from the component root's scoped ID (${root.id}-tab-0). Bascik rewrites root to a different ID in each instance, so two tab groups never share IDs, and aria-controls and aria-labelledby always point at the right element.
  • Left and Right move between tabs and wrap at the ends; Home and End jump to the first and last tab. Only the selected tab is in the Tab order (a roving tabindex).
  • tab.className = 'tab' is a static string, so Bascik rewrites it to the scoped class from the component's <style> block. A template literal would not be rewritten.
  • The tab list is hidden in the markup and shown by the script, so a visitor without JavaScript never sees buttons that do nothing.

Adapt It

  • Give each section a heading if it must make sense when the sections are stacked without JavaScript.
  • To remember the selected tab across reloads, store its index in sessionStorage, keyed by root.id.
  • To open a specific tab from a link, read location.hash on load and select the panel whose data-tab matches.