Roving Tabs Keyboard Navigation
Problem
You need consistent Arrow/Home/End navigation for a tab list while leaving activation policy in component code. Tabs should follow the WAI-ARIA tabs pattern: Arrow keys move focus between tabs without activating them, and activation happens on Space/Enter (manual) or immediately (automatic).
Solution
Create a list-navigation model for the tablist and apply returned changes to roving tabindex and DOM focus. The model owns only navigation state; your component owns focus and activation.
html
<div role="tablist" aria-label="Sections" id="tablist">
<button role="tab" id="tab-overview" aria-controls="panel-overview" aria-selected="true">Overview</button>
<button role="tab" id="tab-usage" aria-controls="panel-usage" tabindex="-1">Usage</button>
<button role="tab" id="tab-api" aria-controls="panel-api" tabindex="-1">API</button>
<button role="tab" id="tab-examples" aria-controls="panel-examples" tabindex="-1" aria-disabled="true">Examples</button>
</div>
<section role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">Overview content</section>
<section role="tabpanel" id="panel-usage" aria-labelledby="tab-usage" hidden>Usage content</section>
<section role="tabpanel" id="panel-api" aria-labelledby="tab-api" hidden>API content</section>
<section role="tabpanel" id="panel-examples" aria-labelledby="tab-examples" hidden>Examples content</section>ts
import { createListNavigation } from '@vielzeug/focus';
const tablist = document.getElementById('tablist')!;
const tabs = Array.from(tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
const nav = createListNavigation({
getItems: () => tabs,
isItemDisabled: (tab) => tab.getAttribute('aria-disabled') === 'true',
loop: true,
orientation: 'horizontal',
});
const applyChange = (change: { index: number; item: HTMLButtonElement } | null) => {
if (!change) return;
tabs.forEach((tab) => {
const isActive = tab === change.item;
tab.tabIndex = isActive ? 0 : -1;
});
change.item.focus();
activeTabIndex = change.index;
};
let activeTabIndex = 0;
tablist.addEventListener('keydown', (event) => {
// Let Focus handle Arrow/Home/End; handle activation separately.
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activateTab(event.target as HTMLButtonElement);
return;
}
applyChange(nav.handleKeydown(event)?.change ?? null);
});
// Keep the handle's index in sync when focus enters from a pointer click.
tablist.addEventListener('click', (event) => {
const tab = (event.target as HTMLElement).closest('[role="tab"]') as HTMLButtonElement | null;
if (!tab) return;
const index = tabs.indexOf(tab);
if (index >= 0) nav.set(index);
});
function activateTab(tab: HTMLButtonElement): void {
tabs.forEach((candidate) => candidate.setAttribute('aria-selected', String(candidate === tab)));
const panelId = tab.getAttribute('aria-controls');
if (!panelId) return;
document.querySelectorAll<HTMLElement>('[role="tabpanel"]').forEach((panel) => {
panel.hidden = panel.id !== panelId;
});
}Pitfalls
- Roving tabindex is your responsibility. Focus returns navigation changes but does not move DOM focus or toggle
tabindex— callset(index)from pointer click handlers so the next Tab keypress lands on the clicked tab, not the previously focused one. - Keep all tabs in
getItems()and mark disabled entries withisItemDisabled. Filtering changes index meaning and can desynchronize pointer-drivenset(index)calls. - Separate focus movement from activation. Automatic activation (focus → activate) is simpler but hostile to screen-magnifier users who Arrow through tabs to read labels. Default to manual activation on Enter/Space.
- Use
set(index)when focus enters from pointer. Otherwise the next Arrow key moves from the last keyboard-focused tab, not the clicked one.