APG Patterns
日本語 GitHub
日本語 GitHub

Tabs

A set of layered sections of content, known as tab panels, that display one panel of content at a time.

🤖 AI Implementation Guide

Demo

Automatic Activation (Default)

Tabs are activated automatically when focused with arrow keys.

This is the overview panel content. It provides a general introduction to the product or service.

Manual Activation

Tabs require Enter or Space to activate after focusing.

Content for tab one. Press Enter or Space to activate tabs.

Vertical Orientation

Tabs arranged vertically with Up/Down arrow navigation.

Configure your application settings here.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
tablist Container Container for tab elements
tab Each tab Individual tab element
tabpanel Panel Content area for each tab

WAI-ARIA tablist role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Configuration
aria-orientation tablist "horizontal" | "vertical" No orientation prop
aria-controls tab ID reference to associated panel Yes Auto-generated
aria-labelledby tabpanel ID reference to associated tab Yes Auto-generated

WAI-ARIA States

aria-selected

Indicates the currently active tab.

Target tab element
Values true | false
Required Yes
Change Trigger Tab click, Arrow keys (automatic), Enter/Space (manual)
Reference aria-selected (opens in new tab)

Keyboard Support

Key Action
Tab Move focus into/out of the tablist
Arrow Right / Arrow Left Navigate between tabs (horizontal)
Arrow Down / Arrow Up Navigate between tabs (vertical)
Home Move focus to first tab
End Move focus to last tab
Enter / Space Activate tab (manual mode only)

Source Code

Tabs.astro
---
/**
 * APG Tabs Pattern - Astro Implementation
 *
 * A set of layered sections of content that display one panel at a time.
 * Uses Web Components for client-side keyboard navigation and state management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
 */

export interface TabItem {
  id: string;
  label: string;
  content: string;
  disabled?: boolean;
}

export interface Props {
  /** Array of tab items */
  tabs: TabItem[];
  /** Initially selected tab ID */
  defaultSelectedId?: string;
  /** Orientation of the tabs */
  orientation?: 'horizontal' | 'vertical';
  /** Activation mode: 'automatic' selects on arrow key, 'manual' requires Enter/Space */
  activation?: 'automatic' | 'manual';
  /** Additional CSS class */
  class?: string;
}

const {
  tabs,
  defaultSelectedId,
  orientation = 'horizontal',
  activation = 'automatic',
  class: className = '',
} = Astro.props;

// Determine initial selected tab
const initialTab = defaultSelectedId
  ? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
  : tabs.find((tab) => !tab.disabled);
const selectedId = initialTab?.id || tabs[0]?.id;

// Generate unique ID for this instance
const instanceId = `tabs-${Math.random().toString(36).substring(2, 11)}`;

const containerClass = `apg-tabs ${
  orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'
} ${className}`.trim();

const tablistClass = `apg-tablist ${
  orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'
}`;
---

<apg-tabs class={containerClass} data-activation={activation} data-orientation={orientation}>
  <div role="tablist" aria-orientation={orientation} class={tablistClass}>
    {
      tabs.map((tab) => {
        const isSelected = tab.id === selectedId;
        const tabClass = `apg-tab ${
          orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
        } ${isSelected ? 'apg-tab--selected' : ''} ${
          tab.disabled ? 'apg-tab--disabled' : ''
        }`.trim();

        return (
          <button
            role="tab"
            type="button"
            id={`${instanceId}-tab-${tab.id}`}
            aria-selected={isSelected}
            aria-controls={isSelected ? `${instanceId}-panel-${tab.id}` : undefined}
            tabindex={tab.disabled ? -1 : isSelected ? 0 : -1}
            disabled={tab.disabled}
            class={tabClass}
            data-tab-id={tab.id}
          >
            <span class="apg-tab-label">{tab.label}</span>
          </button>
        );
      })
    }
  </div>

  <div class="apg-tabpanels">
    {
      tabs.map((tab) => {
        const isSelected = tab.id === selectedId;

        return (
          <div
            role="tabpanel"
            id={`${instanceId}-panel-${tab.id}`}
            aria-labelledby={`${instanceId}-tab-${tab.id}`}
            hidden={!isSelected}
            class={`apg-tabpanel ${isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`}
            tabindex={isSelected ? 0 : -1}
            data-panel-id={tab.id}
          >
            <Fragment set:html={tab.content} />
          </div>
        );
      })
    }
  </div>
</apg-tabs>

<script>
  class ApgTabs extends HTMLElement {
    private tablist: HTMLElement | null = null;
    private tabs: HTMLButtonElement[] = [];
    private panels: HTMLElement[] = [];
    private availableTabs: HTMLButtonElement[] = [];
    private focusedIndex = 0;
    private activation: 'automatic' | 'manual' = 'automatic';
    private orientation: 'horizontal' | 'vertical' = 'horizontal';
    private rafId: number | null = null;

    connectedCallback() {
      // Use requestAnimationFrame to ensure DOM is fully constructed
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.tablist = this.querySelector('[role="tablist"]');
      if (!this.tablist) {
        console.warn('apg-tabs: tablist element not found');
        return;
      }

      this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
      this.panels = Array.from(this.querySelectorAll('[role="tabpanel"]'));

      if (this.tabs.length === 0 || this.panels.length === 0) {
        console.warn('apg-tabs: tabs or panels not found');
        return;
      }

      this.availableTabs = this.tabs.filter((tab) => !tab.disabled);
      this.activation = (this.dataset.activation as 'automatic' | 'manual') || 'automatic';
      this.orientation = (this.dataset.orientation as 'horizontal' | 'vertical') || 'horizontal';

      // Find initial focused index
      this.focusedIndex = this.availableTabs.findIndex(
        (tab) => tab.getAttribute('aria-selected') === 'true'
      );
      if (this.focusedIndex === -1) this.focusedIndex = 0;

      // Attach event listeners
      this.tablist.addEventListener('click', this.handleClick);
      this.tablist.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      // Cancel pending initialization
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      // Remove event listeners
      this.tablist?.removeEventListener('click', this.handleClick);
      this.tablist?.removeEventListener('keydown', this.handleKeyDown);
      // Clean up references
      this.tablist = null;
      this.tabs = [];
      this.panels = [];
      this.availableTabs = [];
    }

    private selectTab(tabId: string) {
      this.tabs.forEach((tab) => {
        const isSelected = tab.dataset.tabId === tabId;
        tab.setAttribute('aria-selected', String(isSelected));
        tab.tabIndex = isSelected ? 0 : -1;
        tab.classList.toggle('apg-tab--selected', isSelected);

        // Update aria-controls
        const panelId = tab.id.replace('-tab-', '-panel-');
        if (isSelected) {
          tab.setAttribute('aria-controls', panelId);
        } else {
          tab.removeAttribute('aria-controls');
        }
      });

      this.panels.forEach((panel) => {
        const isSelected = panel.dataset.panelId === tabId;
        panel.hidden = !isSelected;
        panel.tabIndex = isSelected ? 0 : -1;
        panel.classList.toggle('apg-tabpanel--active', isSelected);
        panel.classList.toggle('apg-tabpanel--inactive', !isSelected);
      });

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('tabchange', {
          detail: { selectedId: tabId },
          bubbles: true,
        })
      );
    }

    private focusTab(index: number) {
      this.focusedIndex = index;
      this.availableTabs[index]?.focus();
    }

    private handleClick = (e: Event) => {
      const target = (e.target as HTMLElement).closest<HTMLButtonElement>('[role="tab"]');
      if (target && !target.disabled) {
        this.selectTab(target.dataset.tabId!);
        // Update focused index
        this.focusedIndex = this.availableTabs.indexOf(target);
      }
    };

    private handleKeyDown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      if (target.getAttribute('role') !== 'tab') return;

      let newIndex = this.focusedIndex;
      let shouldPreventDefault = false;

      // Determine navigation keys based on orientation
      const nextKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
      const prevKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

      switch (e.key) {
        case nextKey:
        case 'ArrowRight':
        case 'ArrowDown':
          newIndex = (this.focusedIndex + 1) % this.availableTabs.length;
          shouldPreventDefault = true;
          break;

        case prevKey:
        case 'ArrowLeft':
        case 'ArrowUp':
          newIndex =
            (this.focusedIndex - 1 + this.availableTabs.length) % this.availableTabs.length;
          shouldPreventDefault = true;
          break;

        case 'Home':
          newIndex = 0;
          shouldPreventDefault = true;
          break;

        case 'End':
          newIndex = this.availableTabs.length - 1;
          shouldPreventDefault = true;
          break;

        case 'Enter':
        case ' ':
          if (this.activation === 'manual') {
            const focusedTab = this.availableTabs[this.focusedIndex];
            if (focusedTab) {
              this.selectTab(focusedTab.dataset.tabId!);
            }
          }
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        e.preventDefault();

        if (newIndex !== this.focusedIndex) {
          this.focusTab(newIndex);

          if (this.activation === 'automatic') {
            const newTab = this.availableTabs[newIndex];
            if (newTab) {
              this.selectTab(newTab.dataset.tabId!);
            }
          }
        }
      }
    };
  }

  // Register the custom element
  if (!customElements.get('apg-tabs')) {
    customElements.define('apg-tabs', ApgTabs);
  }
</script>

Usage

Example
---
import Tabs from './Tabs.astro';

const tabs = [
  { id: 'tab1', label: 'First', content: 'First panel content' },
  { id: 'tab2', label: 'Second', content: 'Second panel content' },
  { id: 'tab3', label: 'Third', content: 'Third panel content' }
];
---

<Tabs
  tabs={tabs}
  defaultSelectedId="tab1"
/>

<script>
  // Listen for tab change events
  document.querySelector('apg-tabs')?.addEventListener('tabchange', (e) => {
    console.log('Tab changed:', e.detail.selectedId);
  });
</script>

API

Props

Prop Type Default Description
tabs TabItem[] required Array of tab items
defaultSelectedId string first tab ID of the initially selected tab
orientation 'horizontal' | 'vertical' 'horizontal' Tab layout direction
activation 'automatic' | 'manual' 'automatic' How tabs are activated
class string "" Additional CSS class

TabItem Interface

Types
interface TabItem {
  id: string;
  label: string;
  content: string;
  disabled?: boolean;
}

Custom Events

Event Detail Description
tabchange { selectedId: string } Fired when the selected tab changes

This component uses a Web Component (<apg-tabs>) for client-side keyboard navigation and state management.

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
ArrowRight/Left Moves focus between tabs (horizontal)
ArrowDown/Up Moves focus between tabs (vertical orientation)
Home/End Moves focus to first/last tab
Loop navigation Arrow keys loop from last to first and vice versa
Disabled skip Skips disabled tabs during navigation
Automatic activation Tab panel changes on focus (default mode)
Manual activation Enter/Space required to activate tab

High Priority: APG ARIA Attributes

Test Description
role="tablist" Container has tablist role
role="tab" Each tab button has tab role
role="tabpanel" Content panel has tabpanel role
aria-selected Selected tab has aria-selected="true"
aria-controls Tab references its panel via aria-controls
aria-labelledby Panel references its tab via aria-labelledby
aria-orientation Reflects horizontal/vertical orientation

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 Selected tab has tabIndex=0
tabIndex=-1 Non-selected tabs have tabIndex=-1
Tab to panel Tab key moves focus from tablist to panel
Panel focusable Panel has tabIndex=0 for focus

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Resources