Horizontal Tablist with Wrapping
A tab control using focusgroup for arrow-key
navigation. The tablist behavior token implies
inline wrap by default, so wrapping is active without
an explicit wrap token. With nomemory
and a little JavaScript to move focusgroupstart,
re-entry always lands on the selected tab instead of the
last-focused one.
Overview
The focusgroup attribute gives composite widgets
declarative arrow-key navigation without JavaScript.
Features
Arrow-key navigation, wrapping, axis locking, focus memory, and grid navigation, all without JavaScript.
Pricing
focusgroup is a native HTML
attribute; no libraries or frameworks needed.
FAQ
Q: Does focusgroup handle tab panel
switching?
A: No. focusgroup
only handles keyboard navigation. Panel switching and
aria-selected state must be managed with
JavaScript.
- The
tablistbehavior token impliesinline wrapby default, so Left/Right navigation with wrap-around works without specifying these tokens explicitly - With
nomemory, re-entry always goes to the selected tab, not the last-focused one (JavaScript movesfocusgroupstartto the selected tab on each selection to make this work) focusgroupstarton the selected tab marks the entry point- JavaScript handles panel visibility and
aria-selectedstate; focusgroup handles navigation
View source
<div focusgroup="tablist nomemory"
aria-label="Sections" class="tablist">
<button type="button" class="tab selected"
aria-selected="true" aria-controls="panel-overview"
id="tab-overview" focusgroupstart>Overview</button>
<button type="button" class="tab"
aria-selected="false" aria-controls="panel-features"
id="tab-features">Features</button>
<button type="button" class="tab"
aria-selected="false" aria-controls="panel-pricing"
id="tab-pricing">Pricing</button>
<button type="button" class="tab"
aria-selected="false" aria-controls="panel-faq"
id="tab-faq">FAQ</button>
</div>
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview"
tabindex="0" class="tabpanel">...</div>
<div role="tabpanel" id="panel-features" aria-labelledby="tab-features"
tabindex="0" class="tabpanel" hidden>...</div>
<div role="tabpanel" id="panel-pricing" aria-labelledby="tab-pricing"
tabindex="0" class="tabpanel" hidden>...</div>
<div role="tabpanel" id="panel-faq" aria-labelledby="tab-faq"
tabindex="0" class="tabpanel" hidden>...</div>
Vertical Tablist
A vertical tablist using block axis for Up/Down
arrow navigation.
General Settings
Configure general application preferences such as language, theme, and notifications.
Privacy Settings
Manage your privacy preferences, data collection, and cookie settings.
Advanced Settings
Advanced configuration options for power users and developers.
blockoverrides the defaultinline wrap. This demo re-addswrapexplicitly to keep wrapping active on the block axis, so Down from the last tab wraps to the first- Pair with
aria-orientation="vertical"for screen readers - Same
wrap,nomemory, and JavaScript-managedfocusgroupstartbehavior as the horizontal version. JavaScript movesfocusgroupstartto the selected tab on each selection.
View source
<div focusgroup="tablist block wrap nomemory"
aria-orientation="vertical" aria-label="Settings" class="tablist-vertical">
<button type="button" class="tab selected"
aria-selected="true" aria-controls="vpanel-general"
id="vtab-general" focusgroupstart>General</button>
<button type="button" class="tab"
aria-selected="false" aria-controls="vpanel-privacy"
id="vtab-privacy">Privacy</button>
<button type="button" class="tab"
aria-selected="false" aria-controls="vpanel-advanced"
id="vtab-advanced">Advanced</button>
</div>
<div role="tabpanel" id="vpanel-general" aria-labelledby="vtab-general"
tabindex="0" class="tabpanel">...</div>
<div role="tabpanel" id="vpanel-privacy" aria-labelledby="vtab-privacy"
tabindex="0" class="tabpanel" hidden>...</div>
<div role="tabpanel" id="vpanel-advanced" aria-labelledby="vtab-advanced"
tabindex="0" class="tabpanel" hidden>...</div>
Long Content: A Possible Mitigation
Because tabs use selection-follows-focus, arrowing between tabs instantly swaps the panel. If a panel has more content than fits on screen, the user may never scroll through it; the next arrow press switches to a different panel entirely.
One possible mitigation: Constrain the panel height and make it a focusable scrollable region. After the panel appears, the user presses Tab to enter the panel, then uses arrow keys to scroll its content. Pressing Tab again exits the panel. Other approaches, such as not using selection-follows-focus, adding a "read more" link, or avoiding tabs entirely for long content, may be more appropriate depending on the use case.
The Problem
When tabs use selection-follows-focus, each arrow press immediately switches the visible panel. This is efficient for short panels; the user can quickly scan all tabs without pressing Enter or Space.
But if a panel is tall, containing a lengthy article, a long form, or detailed documentation, the user may never scroll through it. Pressing → swaps the panel to the next tab's content, and the previous panel's unread content is gone.
Unlike the accordion pattern where arrow keys might skip over expanded content, here the problem is different: the content is replaced rather than skipped. The user might not even realise there was more to read.
Making the panel a constrained, scrollable region with a visible scrollbar hints that more content exists, and lets the user deliberately enter the panel to read it.
The Mitigation
Step 1: Constrain the panel height
Set max-height and overflow:
auto on the
tabpanel. This makes long content scrollable and shows a
scrollbar
as a visual cue that more content exists.
Step 2: Make the panel focusable
Add tabindex="0" to the tabpanel. When the
user presses
Tab from the tablist, focus enters the panel.
Arrow keys
then scroll the panel content instead of switching tabs.
Step 3: Tab to exit
Because the panel is outside the focusgroup, pressing Tab exits it normally. The user can continue to the next focusable element on the page, or Shift+Tab back to the tabs.
Fast tab-switching still works; the user just has an explicit way to read through a long panel before moving on.
When To Use This Pattern
Not every tablist needs scrollable panels. Consider this pattern when:
- Panel content is likely taller than the viewport
- Panels contain long-form text, articles, or documentation
- Panels contain forms or many interactive controls the user must complete
- You want the visible scrollbar to signal "there's more to read"
For short panels (a heading and a paragraph), the standard tablist pattern from Demo 1 is ideal. The user sees all content at a glance and can arrow freely between tabs.
If arrowing away would skip content the user needs to read, constrain the panel height so the scrollbar acts as a visual cue.
- Panels are scrollable regions (
tabindex="0"+max-height+overflow: auto) - Tab from the tablist enters the panel; arrow keys then scroll content
- The visible scrollbar signals that the panel has more content.
- This preserves fast tab switching while preventing content from being missed
View source
<div focusgroup="tablist nomemory"
aria-label="Articles" class="tablist">
<button type="button" class="tab selected"
aria-selected="true" aria-controls="long-tpanel1"
id="long-tab1" focusgroupstart>The Problem</button>
<!-- more tabs ... -->
</div>
<div role="tabpanel" id="long-tpanel1" aria-labelledby="long-tab1"
tabindex="0" class="tabpanel tabpanel-scrollable">
<p>Long panel content...</p>
</div>
<style>
.tabpanel-scrollable {
max-height: 12rem;
overflow: auto;
scroll-behavior: smooth;
}
</style>
Key Point: Navigation vs. Selection
focusgroup handles keyboard navigation
only. It does not manage
selection state (aria-selected), panel visibility,
or any other application logic.
A small amount of JavaScript is still needed for:
- Toggling
aria-selectedon tabs - Showing/hiding tab panels
- Moving
focusgroupstartto the selected tab (sonomemoryre-entry lands on the active tab)
But you no longer need any JavaScript for focus management,
tabindex roving, or keyboard event handlers for
arrow keys. The browser handles the roving tab stop
natively.