CONTRIBUTOR-DOCS / Project planning / Components / Tabs / Tabs accessibility migration analysis
In this doc
This doc describes how swc-tabs (with swc-tab and swc-tab-panel) should behave for accessibility in 2nd-gen, targeting WCAG 2.2 Level AA. It aligns with the WAI-ARIA tabs pattern, documents automatic vs manual activation, and ties tablist keyboard behavior to the proposed FocusgroupNavigationController (spectrum-web-components#6129).
Tabs migration roadmap for layout, CSS, and DOM migration (placeholder until expanded).
- Tabbed interface: a
tablistcontainingtabcontrols and associatedtabpanelregions. Only one panel is visible at a time; the selected tab exposesaria-selectedand is wired to its panel witharia-controls(tab → panel) andaria-labelledby(panel → tab) per APG.
- Same-page sections that do not need desktop-style tab keyboard semantics are often better as a table of contents and in-page links to headings or regions. Inclusive Components on tabbed interfaces argues that progressive enhancement from TOC + sections can be simpler and more robust than ARIA tabs when the UX does not require true tab ergonomics.
- Site-wide navigation should not be styled or implemented as tabs if users would expect links and full-page navigation instead (Inclusive Components).
- Single-page application views or routes should be communicated as distinct pages or regions (focus management,
<title>, links), not astabpanelsemantics, unless the UI is explicitly a tabs widget (Inclusive Components — When panels are views).
- Not a menu:
tablist,tab, andtabpaneluse different roles and keys thanmenu/menuitem. For command menus, use the APG Menu button pattern.
- The Tabs pattern is the normative APG reference. Two examples differ by activation model:
- Automatic: Tabs with automatic activation — when a tab receives focus (for example via Left / Right Arrow), it is activated and its panel is shown immediately. APG recommends this only when all panel content is already in the DOM and can be shown without delay (see Deciding When to Make Selection Automatically Follow Focus linked from the example).
- Manual: Tabs with manual activation — Arrow keys move focus between tabs without changing the selected tab; the user activates the focused tab with Space or Enter (or click). Use this when panels are expensive to render, lazy-loaded, or not all present in the DOM at once.
- Deque University — Tabpanel illustrates
role="tablist",role="tab"witharia-controlsandaria-selected, androle="tabpanel"as a compact reference for tab–panel relationships (still verify keys and activation against APG). - Inclusive Components — Tabbed interfaces covers roving
tabindex(Tab skips inactive tabs; arrows move within the tablist), focusabletabpanel(or first focusable child) so screen reader users do not miss panel content, responsive layouts (avoid tabs-to-accordion hybrid complexity), and when many tabs make accordions preferable.
<sp-tabs> maps APG manual vs automatic activation to the boolean auto attribute / property (default false). The JSDoc points authors to Deciding When to Make Selection Automatically Follow Focus (same idea as the APG examples).
/**
* Whether to activate a tab on keyboard focus or not.
*
* By default a tab is activated via a "click" interaction. This is specifically intended for when
* tab content cannot be displayed instantly, e.g. not all of the DOM content is available, etc.
* To learn more about "Deciding When to Make Selection Automatically Follow Focus", visit:
* https://w3c.github.io/aria-practices/#kbd_selection_follows_focus
*/
@property({ type: Boolean })
public auto = false;- Manual pattern (
autoomitted orfalse) — matches tabs with manual activation:RovingTabindexControllermoves focus betweensp-tabelements with arrow keys, butelementEnterActiondoes not change selection whenautois false. The user activates the focused tab with Enter or Space (or click).onKeyDownon the tablist callsselectTargetfor Enter and Space only.
rovingTabindexController = new RovingTabindexController<Tab>(this, {
focusInIndex: (elements) => {
let focusInIndex = 0;
const firstFocusableElement = elements.find((el, index) => {
const focusInElement = this.selected
? el.value === this.selected
: !el.disabled;
focusInIndex = index;
return focusInElement;
});
return firstFocusableElement ? focusInIndex : -1;
},
direction: () => 'both',
elementEnterAction: (el) => {
if (!this.auto) {
return;
}
this.shouldAnimate = true;
this.selectTarget(el);
},
elements: () => this.tabs,
isFocusableElement: (el) => !this.disabled && !el.disabled,
listenerScope: () => this.tabList,
}); private onKeyDown = (event: KeyboardEvent): void => {
if (event.code === 'Enter' || event.code === 'Space') {
event.preventDefault();
const target = event.target as HTMLElement;
if (target) {
this.selectTarget(target);
}
}
};- Automatic pattern (
autotrue) — matches tabs with automatic activation: when roving focus enters a tab,elementEnterActionrunsselectTarget(el), so selection (and visible panel) follows arrow navigation without a separate Enter or Space. Click still selects viaonClick.
2nd-gen note: 1st-gen uses RovingTabindexController from @spectrum-web-components/reactive-controllers. 2nd-gen should use FocusgroupNavigationController (#6129) for arrow navigation inside the tablist while preserving the same auto semantics (selection on focus move vs selection only on activate).
| Idea | Plain meaning |
|---|---|
| Tabs pattern (APG) | tablist, tab, tabpanel; aria-selected; aria-controls; aria-labelledby on panels; roving tabindex on tabs per APG examples. |
| Info and relationships (WCAG 1.3.1) | The selected tab and visible panel relationship must be exposed in the accessibility tree. |
| Keyboard (WCAG 2.1.1) | All tab selection and navigation within the widget must work without a pointer. |
| Focus order (WCAG 2.4.3) | Tab from the tablist should move to meaningful content (tabpanel with tabindex="0" when needed, or first focusable in panel) per APG guidance. |
| Name, role, value (WCAG 4.1.2) | Each tab needs a name; state (aria-selected) must update; panels hidden with hidden or equivalent must not leave stale state exposed. |
Bottom line: Choose automatic vs manual activation from real performance and DOM shape; implement APG keyboard tables for the chosen model; keep tab↔panel references valid in the composed tree (see Shadow DOM below).
| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary |
|---|---|---|---|---|
| — | — | — | — | No issues listed. |
| Topic | What to do |
|---|---|
| Prescribed structure | swc-tabs (or its internal wrapper) exposes role="tablist" with an accessible name (aria-label or aria-labelledby). Each swc-tab exposes role="tab"; each swc-tab-panel exposes role="tabpanel". Do not map this component to different ARIA widgets (menu, listbox) via author overrides on the host. |
aria-selected |
true on the active tab; false on all others. Must stay in sync with the visible panel. |
aria-controls |
On each tab, reference the id of the associated tabpanel (same root as ID resolution requires — see Shadow DOM). |
aria-labelledby |
On each tabpanel, reference the id of its controlling tab for an accessible name. |
| Hidden panels | Inactive panels use hidden (or visibility/display patterns that hide from AT) per APG; do not leave inert content falsely exposed. |
tabindex on tabpanel |
Per automatic activation example, tabindex="0" on tabpanel helps users move from tablist into panel content when the first element inside is not focusable. Manual example may omit tabpanel tabindex when the first child is focusable — match APG for your story variants. |
| Vertical orientation | If the tablist is vertical, set aria-orientation="vertical" and arrow key mapping consistent with APG (Up/Down or Left/Right per spec and docs). |
| Activation mode (API) | Mirror 1st-gen auto (boolean, default off = manual): auto false — arrows move focus only; Enter, Space, or click select; auto true — selection follows focus when arrows move between tabs. Document and test both modes; default manual matches lazy or heavy panels. |
aria-controls and aria-labelledby rely on ID references that must resolve in the document tree. If tabs and panels split across shadow roots without a supported cross-root strategy, IDs may not resolve as expected. Prefer a composition where references resolve in the same document subtree as the referencing node, or document the 2nd-gen plan (ElementInternals, explicit light DOM slots, synchronized ids on light children, etc.) once implementation exists. Until then, treat cross-root IDREF as a design constraint to solve in the rendering migration.
Tablist
- Role:
tablistwith a discernible name. - Children:
tabelements with names from text oraria-label.
Selected tab
aria-selected="true", in tab order per APG roving pattern (typically only the selected tab hastabindex="0", others-1— verify against the chosen APG example).
Tabpanel
- Role:
tabpanel; name fromaria-labelledbypointing at the tab. - Visibility: inactive panels hidden from AT and sight per pattern.
- Implement APG keyboard behavior for the chosen activation model (automatic vs manual): Tab into/out of tablist; arrow keys within tablist; Home / End where APG shows them; Space / Enter for manual activation.
- Use
FocusgroupNavigationController(#6129) for linear arrow navigation and rovingtabindexwithin thetablistwhere it matches the controller’s model. Tab moving focus from tablist to panel (and Shift+Tab back) is focus traversal between groups, not only intra-group arrows — implement in the tabs component (or a small tabs-specific helper) unless a generalized “exit group on Tab” behavior is added to shared infrastructure for multiple widgets.
When implementation gaps appear, classify missing behavior:
| If the behavior is… | Prefer… |
|---|---|
| Shared broadly (Home/End, wrap, orientation for most roving tabindex / focus groups) | Extend FocusgroupNavigationController (or a shared core utility) so menus, toolbars, tabs, etc. stay consistent. |
| Shared narrowly (e.g. tabs + toolbar, but not every group) | Add a small composable helper or optional controller mode rather than duplicating in each component. |
Tabs-specific (automatic vs manual activation, selection following focus, sync with aria-selected, Tab to tabpanel) |
Keep in swc-tabs (or a tabs-only controller) unless refactoring reveals a reusable abstraction worth promoting upward. |
Activation semantics: Automatic activation is selection on focus change inside the tablist; manual activation decouples focused tab from aria-selected until the user confirms — that state machine is tabs-specific even if arrows use the shared controller.
| Kind of test | What to check |
|---|---|
| Unit | Selection state mirrors aria-selected; only one selected tab; panel visibility matches selection; activation mode flag behaves as documented. |
| aXe + Storybook | WCAG 2.x rules on tabs stories (horizontal / vertical if supported). |
| Playwright ARIA snapshots | Selected vs unselected tabs tabpanel visibility; relationship attributes present. Separate stories or snapshots for automatic vs manual activation. |
| Playwright keyboard | Tab into tablist and out to panel; arrows within tablist; Home/End if implemented; manual mode: Space/Enter changes selection; automatic mode: arrow moves selection with focus. |
| Contrast / focus | Selected vs unselected tabs and focus ring discernible (non-text contrast / focus appearance per design system). |
-
tablist/tab/tabpanelroles and labelling match APG. - Automatic vs manual activation documented and tested; default matches content loading model.
- Roving
tabindexand arrows useFocusgroupNavigationControllerwhere appropriate (#6129); tabs-only logic stays in component unless promoted to shared infra deliberately. -
aria-controls/aria-labelledbyvalid in composed tree (no silent cross-root ID failures). -
tabpanelfocus behavior matches APG for focusable vs non-focusable first content. - Automated snapshots and keyboard tests cover both activation modes where both ship.
- Manual smoke with VoiceOver and NVDA (or project standard).
- WAI-ARIA APG: Tabs pattern
- WAI-ARIA APG: Example of tabs with automatic activation
- WAI-ARIA APG: Example of tabs with manual activation
- Deque University: Tabpanel
- Inclusive Components: Tabbed interfaces
- spectrum-web-components PR #6129 — Focusgroup navigation controller
- WCAG 2.2
- Tabs migration roadmap