Loading Developer Playground

Loading ...

Skip to main content
ARIA ROLEWidget Roles

tabpanel

A container for the content associated with a tab. Tab panels display content when their corresponding tab is selected, working together with tablist and tab roles to create a tabbed interface.

Related Roles
tablist, tab
Key Relationship
aria-labelledby → tab
Focus
tabindex="0"

Overview

The tabpanel role identifies an element as a container for content associated with a tab. When a tab is selected, its corresponding tabpanel becomes visible while others are hidden.

Tab panels must be associated with their controlling tabs using aria-labelledby (referencing the tab's ID) or aria-label. The tab should have aria-controls pointing to the panel's ID.

Tab Pattern Overview

A complete tab interface requires three roles working together: tablist (container for tabs), tab (the clickable tab buttons), and tabpanel (the content containers).

Live Demo: Tab Interfaces

Horizontal Tabs

Overview

This is the content for the overview tab panel. It becomes visible when the corresponding tab is selected.

Vertical Tabs

Account Settings

Configure your account preferences here.

Keyboard support: Use for horizontal tabs, for vertical tabs. Home and End jump to first/last tab.

Code Examples

Basic Tab Structure

<!-- Basic Tab Panel Structure -->
<div class="tabs">
  <!-- Tab List -->
  <div role="tablist" aria-label="Product Information">
    <button 
      role="tab"
      aria-selected="true"
      aria-controls="panel-1"
      id="tab-1"
      tabindex="0"
    >
      Overview
    </button>
    <button 
      role="tab"
      aria-selected="false"
      aria-controls="panel-2"
      id="tab-2"
      tabindex="-1"
    >
      Features
    </button>
    <button 
      role="tab"
      aria-selected="false"
      aria-controls="panel-3"
      id="tab-3"
      tabindex="-1"
    >
      Pricing
    </button>
  </div>

  <!-- Tab Panels -->
  <div 
    role="tabpanel"
    id="panel-1"
    aria-labelledby="tab-1"
    tabindex="0"
  >
    <h2>Product Overview</h2>
    <p>Overview content here...</p>
  </div>
  
  <div 
    role="tabpanel"
    id="panel-2"
    aria-labelledby="tab-2"
    tabindex="0"
    hidden
  >
    <h2>Features</h2>
    <p>Features content here...</p>
  </div>
  
  <div 
    role="tabpanel"
    id="panel-3"
    aria-labelledby="tab-3"
    tabindex="0"
    hidden
  >
    <h2>Pricing</h2>
    <p>Pricing content here...</p>
  </div>
</div>

Keyboard Navigation

<!-- Tab Implementation with Full Keyboard Support -->
<div class="tabs">
  <div role="tablist" aria-label="Settings" id="settings-tablist">
    <button role="tab" id="tab-account" aria-selected="true" 
            aria-controls="panel-account" tabindex="0">Account</button>
    <button role="tab" id="tab-security" aria-selected="false"
            aria-controls="panel-security" tabindex="-1">Security</button>
    <button role="tab" id="tab-notifications" aria-selected="false"
            aria-controls="panel-notifications" tabindex="-1">Notifications</button>
  </div>
  
  <div role="tabpanel" id="panel-account" aria-labelledby="tab-account">
    <!-- Account content -->
  </div>
</div>

<script>
  const tablist = document.getElementById('settings-tablist');
  const tabs = tablist.querySelectorAll('[role="tab"]');
  const panels = document.querySelectorAll('[role="tabpanel"]');

  tabs.forEach((tab, index) => {
    tab.addEventListener('click', () => switchTab(index));
    
    tab.addEventListener('keydown', (e) => {
      let newIndex = index;
      
      switch (e.key) {
        case 'ArrowRight':
          e.preventDefault();
          newIndex = (index + 1) % tabs.length;
          break;
        case 'ArrowLeft':
          e.preventDefault();
          newIndex = (index - 1 + tabs.length) % tabs.length;
          break;
        case 'Home':
          e.preventDefault();
          newIndex = 0;
          break;
        case 'End':
          e.preventDefault();
          newIndex = tabs.length - 1;
          break;
        default:
          return;
      }
      
      switchTab(newIndex);
      tabs[newIndex].focus();
    });
  });

  function switchTab(index) {
    // Update tabs
    tabs.forEach((tab, i) => {
      tab.setAttribute('aria-selected', i === index);
      tab.setAttribute('tabindex', i === index ? '0' : '-1');
    });
    
    // Update panels
    panels.forEach((panel, i) => {
      panel.hidden = i !== index;
    });
  }
</script>

Vertical Tabs

<!-- Vertical Tab List -->
<div class="vertical-tabs">
  <div 
    role="tablist" 
    aria-label="Settings sections"
    aria-orientation="vertical"
    class="tab-sidebar"
  >
    <button role="tab" aria-selected="true" aria-controls="v-panel-1"
            id="v-tab-1" tabindex="0">General</button>
    <button role="tab" aria-selected="false" aria-controls="v-panel-2"
            id="v-tab-2" tabindex="-1">Display</button>
    <button role="tab" aria-selected="false" aria-controls="v-panel-3"
            id="v-tab-3" tabindex="-1">Sound</button>
  </div>
  
  <div class="tab-content">
    <div role="tabpanel" id="v-panel-1" aria-labelledby="v-tab-1">
      <!-- General settings -->
    </div>
    <!-- Other panels -->
  </div>
</div>

<!-- For vertical tabs:
     - Use aria-orientation="vertical"
     - Arrow Up/Down navigate (not Left/Right)
     - Home/End still work the same -->

React Component

// React Tabs Component
import { useState, useRef, useCallback } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

interface TabsProps {
  tabs: Tab[];
  ariaLabel: string;
  orientation?: 'horizontal' | 'vertical';
}

function Tabs({ tabs, ariaLabel, orientation = 'horizontal' }: TabsProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
    const isVertical = orientation === 'vertical';
    const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
    const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
    
    let newIndex = index;

    switch (e.key) {
      case nextKey:
        e.preventDefault();
        newIndex = (index + 1) % tabs.length;
        break;
      case prevKey:
        e.preventDefault();
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    setActiveIndex(newIndex);
    tabRefs.current[newIndex]?.focus();
  }, [tabs.length, orientation]);

  return (
    <div className={`tabs ${orientation}`}>
      <div
        role="tablist"
        aria-label={ariaLabel}
        aria-orientation={orientation}
        className="tab-list"
      >
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={index === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={`tab ${index === activeIndex ? 'active' : ''}`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          tabIndex={0}
          hidden={index !== activeIndex}
          className="tab-panel"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Usage
const productTabs = [
  {
    id: 'overview',
    label: 'Overview',
    content: <div><h2>Product Overview</h2><p>Description...</p></div>,
  },
  {
    id: 'features',
    label: 'Features',
    content: <div><h2>Features</h2><ul><li>Feature 1</li></ul></div>,
  },
  {
    id: 'pricing',
    label: 'Pricing',
    content: <div><h2>Pricing</h2><p>Starting at $9.99</p></div>,
  },
];

<Tabs tabs={productTabs} ariaLabel="Product information" />

Automatic vs Manual Activation

<!-- Automatic vs Manual Tab Activation -->

<!-- AUTOMATIC ACTIVATION (Recommended) -->
<!-- Tab activates immediately on focus -->
<div role="tablist" aria-label="Automatic tabs">
  <button role="tab" aria-selected="true" ...>Tab 1</button>
  <button role="tab" aria-selected="false" ...>Tab 2</button>
</div>

<script>
  // Automatic: activate on focus
  tab.addEventListener('focus', () => {
    switchTab(index);
  });
  
  tab.addEventListener('keydown', (e) => {
    // Arrow keys move focus AND activate
    if (e.key === 'ArrowRight') {
      tabs[nextIndex].focus(); // This triggers activation
    }
  });
</script>

<!-- MANUAL ACTIVATION -->
<!-- User must press Enter/Space after focusing -->
<div role="tablist" aria-label="Manual tabs">
  <button role="tab" aria-selected="true" ...>Tab 1</button>
  <button role="tab" aria-selected="false" ...>Tab 2</button>
</div>

<script>
  // Manual: focus doesn't activate
  tab.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowRight') {
      e.preventDefault();
      tabs[nextIndex].focus(); // Just move focus
    }
    if (e.key === 'Enter' || e.key === ' ') {
      switchTab(focusedIndex); // Explicitly activate
    }
  });
</script>

<!-- Automatic activation is generally preferred for better UX -->

Dynamic/Lazy-Loaded Content

<!-- Tab Panel with Lazy-Loaded Content -->
<div class="tabs">
  <div role="tablist" aria-label="Dashboard sections">
    <button role="tab" aria-selected="true" 
            aria-controls="panel-analytics" id="tab-analytics">
      Analytics
    </button>
    <button role="tab" aria-selected="false"
            aria-controls="panel-reports" id="tab-reports">
      Reports
    </button>
  </div>

  <div 
    role="tabpanel" 
    id="panel-analytics" 
    aria-labelledby="tab-analytics"
    aria-busy="false"
    tabindex="0"
  >
    <!-- Content loaded -->
  </div>
  
  <div 
    role="tabpanel" 
    id="panel-reports" 
    aria-labelledby="tab-reports"
    aria-busy="true"
    tabindex="0"
    hidden
  >
    <!-- Content loading... -->
    <div role="status" aria-live="polite">
      Loading reports...
    </div>
  </div>
</div>

<!-- Use aria-busy="true" while content is loading
     Use aria-live region to announce when loading completes -->

Keyboard Support

Keyboard controls apply when focus is on a tab within the tablist:

Moves focus to next tab (horizontal)

Wraps from last to first

Moves focus to previous tab (horizontal)

Wraps from first to last

Moves focus to next tab (vertical)

When aria-orientation="vertical"

Moves focus to previous tab (vertical)

When aria-orientation="vertical"

Home
Moves focus to first tab

Works in both orientations

End
Moves focus to last tab

Works in both orientations

Tab
Moves focus into the tab panel

From the active tab

Best Practices

Use aria-labelledby on tabpanel to reference its controlling tab

Set tabindex="0" on tab panels to allow keyboard focus

Use hidden attribute or display:none for inactive panels

Implement roving tabindex on tabs (only active tab is tabbable)

Use aria-orientation="vertical" for vertically stacked tabs

Ensure tab panel content is fully accessible when visible

×

Don't use tabs for navigation between pages (use links instead)

×

Don't nest tab interfaces within each other

×

Don't remove hidden panels from DOM if they have focusable elements

×

Don't forget the bidirectional relationship between tab and tabpanel

Supported ARIA Attributes

aria-labelledby

References the tab that controls this panel

aria-label

Accessible name if no visible label exists

aria-busy

Indicates if panel content is loading

tabindex="0"

Makes the panel keyboard focusable

hidden

Hides inactive panels

Common Use Cases

Product information sections
Settings/preferences panels
Dashboard widget containers
Documentation sections
Form wizard steps (visible)
Code editor language panels
Email/message folders
Media gallery categories

Accessibility Notes

Focus Flow

When tabbing from the tab list, focus should move to the active tab panel. Setting tabindex="0" on panels allows users to focus them directly, then Tab into interactive content within.

Screen Reader Announcements

Screen readers announce tab panels with their label (from aria-labelledby). When switching tabs, the new panel's content should be announced. Consider using aria-live regions for dynamically loaded content.

Tabs vs Navigation

Tabs should show/hide content on the same page. If clicking a "tab" navigates to a new URL, use regular links with proper navigation patterns instead. Tabs are for in-page content organization, not site navigation.

Related Roles & Attributes

Specifications & Resources