Decide how the tabs should behave
A tabbed interface shows one panel from a related set. Selecting a tab should make its panel visible, hide the others, and update the information exposed to assistive technology. These changes belong in one operation: if the visible panel and selected tab can be updated separately, they can drift out of sync.
The example below uses automatic activation. Pressing Left Arrow, Right Arrow, Home, or End moves focus to a tab and selects it immediately. This works well when switching panels is quick and does not trigger a costly action. If selecting a tab starts a slow request, a manual-activation design may be more appropriate: arrow keys would move focus, while Enter or Space would select the focused tab.
The environment is a browser document with JavaScript running after the document body exists, such as a deferred script. The example creates its own elements, so it does not depend on a framework or prewritten page markup. Place the code in that script and run it in a browser to try the completed component.
Create the tabs and their matching panels
Each tab is a button inside a tab list. Its aria-controls value names the panel it controls; the panel’s aria-labelledby value points back to the tab. A distinct pair of IDs is needed for every tab and panel. The prefix passed to this function must be unique if a page contains more than one instance.
Here is the complete example: function createTabs(items, prefix) { const root = document.createElement('div'); const list = document.createElement('div'); list.setAttribute('role', 'tablist'); list.setAttribute('aria-label', 'Example topics'); root.append(list); const tabs = []; const panels = []; items.forEach((item, index) => { const tab = document.createElement('button'); const panel = document.createElement('section'); tab.type = 'button'; tab.textContent = item.label; tab.id = `${prefix}-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', `${prefix}-panel-${index}`); panel.id = `${prefix}-panel-${index}`; panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', tab.id); panel.tabIndex = 0; panel.textContent = item.content; list.append(tab); root.append(panel); tabs.push(tab); panels.push(panel); tab.addEventListener('click', () => select(index)); tab.addEventListener('keydown', event => { let next; if (event.key === 'ArrowRight') next = (index + 1) % tabs.length; if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length; if (event.key === 'Home') next = 0; if (event.key === 'End') next = tabs.length - 1; if (next === undefined) return; event.preventDefault(); select(next); tabs[next].focus(); }); }); function select(selectedIndex) { tabs.forEach((tab, index) => { const selected = index === selectedIndex; tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; panels[index].hidden = !selected; }); } select(0); return root; } const topics = [ { label: 'Overview', content: 'Start with the component’s purpose.' }, { label: 'Details', content: 'Read how its parts work together.' }, { label: 'Next steps', content: 'Try the keyboard controls.' } ]; document.body.append(createTabs(topics, 'topic-example'));
The labels and panel contents are text for simplicity. In a real interface, a panel can contain headings, links, or form controls. Keep each tab’s short label descriptive enough to identify its panel without relying on the panel being visible. Name the tab list for its subject; the generic label in the example should be replaced when its context is known.
Keep selection, visibility, and focus coordinated
The select function is the component’s single source of truth. It sets aria-selected to true on exactly one tab, gives that tab a tabIndex of 0, and hides every panel except its match. The other tabs receive a tabIndex of -1, so a person pressing Tab enters the tab list at the selected tab rather than stopping on every tab. Hidden panels are removed from the normal reading and keyboard flow.
Arrow-key handling changes both selection and focus. The event’s index identifies the tab that received the key; wrapping arithmetic moves from the last tab to the first and back. Home and End jump directly to the first and last tabs. Preventing the default action for these handled keys avoids competing browser behavior, such as scrolling. A mouse click calls select without forcing a separate focus move.
Each panel also has a tabIndex of 0. That gives a keyboard user a predictable place to land when pressing Tab from the selected tab, even when the panel contains only text. If a panel starts with a naturally focusable control, consider whether an extra panel stop is useful for that particular design.
Check the component as a user would
Start with the first tab selected and only its panel visible. Click every tab and confirm that its matching panel appears. Then use only the keyboard: Tab into the tab list, try both arrow keys and Home and End, and press Tab again to reach the visible panel. Check that focus remains visible throughout; the page’s CSS should not remove the button’s focus indicator without providing a clear replacement.
Inspect the resulting elements as well. Every aria-controls value should match a panel ID, every panel should point back to its tab, and only one tab should report aria-selected as true. These checks catch mistakes that a visual test can miss. If panels later gain their own interactive controls or load content asynchronously, repeat the keyboard test with that real content rather than assuming the simple example still covers every case.



