DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #37: Tabs — Scroll-Snap, ARIA, and Keyboard Arrows

Tabs | Component Deep Dive #37: Tabs — Scroll-Snap, ARIA, and Keyboard Arrows

User clicks a tab, content switches. Looks simple, but 90% of implementations forget keyboard users and screen readers.

Tabs are the foundation of information density management. When a page has too much content to display at once, tabs group it into sections users switch between. But "switchable" is only the starting point — a real tab component must satisfy the complete interaction spec of the WAI-ARIA Tab Pattern.

Core Principle

Tabs consist of two parts: the tab list (tablist) and content panels (tabpanel). Each tab points to its panel via aria-controls, and each panel points back to its tab via aria-labelledby. The selected state is marked with aria-selected="true", and unselected panels are hidden with the hidden attribute.

The key is focus management: the tab list uses roving tabindex — only the currently selected tab has tabindex="0", all others have tabindex="-1". Users move focus between tabs with left/right arrow keys, and the panel switches automatically as focus moves.

Implementation

<div class="tabs">
  <div class="tablist" role="tablist" aria-label="Project Settings">
    <button class="tab" role="tab" id="tab-general"
      aria-controls="panel-general" aria-selected="true" tabindex="0">
      General
    </button>
    <button class="tab" role="tab" id="tab-advanced"
      aria-controls="panel-advanced" aria-selected="false" tabindex="-1">
      Advanced
    </button>
    <button class="tab" role="tab" id="tab-permissions"
      aria-controls="panel-permissions" aria-selected="false" tabindex="-1">
      Permissions
    </button>
  </div>
  <div class="tabpanel" role="tabpanel" id="panel-general"
    aria-labelledby="tab-general">
    <p>General settings content</p>
  </div>
  <div class="tabpanel" role="tabpanel" id="panel-advanced"
    aria-labelledby="tab-advanced" hidden>
    <p>Advanced settings content</p>
  </div>
  <div class="tabpanel" role="tabpanel" id="panel-permissions"
    aria-labelledby="tab-permissions" hidden>
    <p>Permissions settings content</p>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const panels = Array.from(document.querySelectorAll('[role="tabpanel"]'));

function activateTab(tab) {
  // Deactivate all
  tabs.forEach(t => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
  });
  panels.forEach(p => p.setAttribute('hidden', ''));

  // Activate selected
  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  tab.focus();
  document.getElementById(tab.getAttribute('aria-controls'))
    .removeAttribute('hidden');
}

tablist.addEventListener('click', e => {
  const tab = e.target.closest('[role="tab"]');
  if (tab) activateTab(tab);
});

tablist.addEventListener('keydown', e => {
  const idx = tabs.indexOf(document.activeElement);
  if (idx === -1) return;

  const horiz = tablist.getAttribute('aria-orientation') !== 'vertical';
  let newIdx = idx;

  if ((horiz && e.key === 'ArrowRight') ||
      (!horiz && e.key === 'ArrowDown')) {
    e.preventDefault();
    newIdx = (idx + 1) % tabs.length;
  } else if ((horiz && e.key === 'ArrowLeft') ||
             (!horiz && e.key === 'ArrowUp')) {
    e.preventDefault();
    newIdx = (idx - 1 + tabs.length) % tabs.length;
  } else if (e.key === 'Home') {
    e.preventDefault();
    newIdx = 0;
  } else if (e.key === 'End') {
    e.preventDefault();
    newIdx = tabs.length - 1;
  }

  if (newIdx !== idx) activateTab(tabs[newIdx]);
});
Enter fullscreen mode Exit fullscreen mode

Horizontal Scroll Tabs

When there are too many tabs, don't wrap — use horizontal scrolling with scroll-snap:

.tablist {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.tablist::-webkit-scrollbar { display: none; }

.tab {
  scroll-snap-align: start;
  flex-shrink: 0;
  padding: 12px 20px;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  cursor: pointer;
  white-space: nowrap;
  font: inherit;
  color: #666;
  transition: color 0.2s, border-color 0.2s;
}

.tab[aria-selected="true"] {
  color: #2563eb;
  border-bottom-color: #2563eb;
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

  1. Using display:none to switch panelsdisplay:none causes iframes/videos inside panels to reload. The hidden attribute is better — it's semantically equivalent but clearer. To preserve state, use visibility:hidden with position:absolute.

  2. Forgetting arrow key navigation — Many implementations only support click switching. Keyboard users must press Tab through every tab before reaching content. Correct approach: roving tabindex + arrow keys.

  3. Wrong Tab key behavior — Pressing Tab should move focus from the current tab directly to its panel content, not to the next tab. Roving tabindex ensures only the active tab is in the tab sequence.

  4. Missing ARIA states — Screen reader users need aria-selected to know which tab is active, and aria-controls to know which panel a tab governs. Without these attributes, tabs are completely invisible to assistive technology.

Underlying Logic

Tabs are essentially a state machine: activeIndex determines which tab is selected and which panel is visible. All interactions — clicks, arrow keys, Home/End — simply update activeIndex, then a single activateTab function synchronizes the DOM.

The core idea of roving tabindex: manage focus within a container using directional keys, and let Tab key only jump between containers. This gives keyboard users the same navigation efficiency as mouse users — one keypress per tab switch.


本文是「组件深度解析」系列第37篇。每篇拆解一个前端组件的核心原理与实现细节。

Top comments (0)