Underline Tabs
A keyboard-accessible tab strip with the active tab underlined.
6 frameworksIntermediate
A tab rail down the left with the panel beside it.
<!--
Vertical tabs. Two things change with the axis, and both matter:
1. aria-orientation="vertical" on the tablist, which tells the screen reader
(and the user) that Up/Down are the movement keys here.
2. the script binds ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight.
Getting the CSS to stack without doing (1) and (2) is the classic bug - the
strip looks vertical but only answers to left/right.
-->
<div class="tabs-vert" data-tabs-vert>
<div class="tabs-vert__list" role="tablist" aria-label="Settings sections" aria-orientation="vertical">
<button class="tabs-vert__tab" type="button" role="tab" id="vert-profile" aria-controls="vert-panel-profile" aria-selected="true" tabindex="0">
Profile
</button>
<button class="tabs-vert__tab" type="button" role="tab" id="vert-security" aria-controls="vert-panel-security" aria-selected="false" tabindex="-1">
Security
</button>
<button class="tabs-vert__tab" type="button" role="tab" id="vert-billing" aria-controls="vert-panel-billing" aria-selected="false" tabindex="-1">
Billing
</button>
<button class="tabs-vert__tab" type="button" role="tab" id="vert-api" aria-controls="vert-panel-api" aria-selected="false" tabindex="-1">
API keys
</button>
</div>
<div class="tabs-vert__panel" role="tabpanel" id="vert-panel-profile" aria-labelledby="vert-profile" tabindex="0">
<p>Your display name and avatar are visible to everyone in the workspace.</p>
</div>
<div class="tabs-vert__panel" role="tabpanel" id="vert-panel-security" aria-labelledby="vert-security" tabindex="0" hidden>
<p>Two-factor authentication is on. Recovery codes were generated in April.</p>
</div>
<div class="tabs-vert__panel" role="tabpanel" id="vert-panel-billing" aria-labelledby="vert-billing" tabindex="0" hidden>
<p>The Team plan renews on 1 August. Invoices go to billing@example.com.</p>
</div>
<div class="tabs-vert__panel" role="tabpanel" id="vert-panel-api" aria-labelledby="vert-api" tabindex="0" hidden>
<p>Three live keys. The oldest has not been used in 60 days - consider revoking it.</p>
</div>
</div>
<script>
(function () {
document.querySelectorAll('[data-tabs-vert]').forEach(function (root) {
var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
function select(index, setFocus) {
tabs.forEach(function (tab, i) {
var isSelected = i === index;
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
if (panels[i]) panels[i].hidden = !isSelected;
});
if (setFocus) tabs[index].focus();
}
tabs.forEach(function (tab, i) {
tab.addEventListener('click', function () {
select(i, false);
});
tab.addEventListener('keydown', function (event) {
var next = -1;
// Vertical orientation: Down/Up, not Right/Left.
if (event.key === 'ArrowDown') next = (i + 1) % tabs.length;
else if (event.key === 'ArrowUp') next = (i - 1 + tabs.length) % tabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
select(next, true);
});
});
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | TabItem[] | - | The array of items to render. |
defaultTabId | string | - | Id of the item expanded on mount. |
onSelect | (id: string) => void | - | Fired with the menu item the user chose. |
className | string | - | Additional classes merged onto the root element. |
Turning tabs vertical is two changes, not one: `aria-orientation="vertical"` on the tablist, and Up/Down bound instead of Left/Right. Doing only the CSS is the classic bug - a strip that looks vertical but answers to the wrong keys. The rail folds above the panel below `sm` because a 11rem rail plus prose does not fit a phone; the roles and keys are untouched by that fold. Good for settings pages where the sections outnumber what a horizontal strip can hold.