0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up
Seven vertical tab designs on the ARIA tabs pattern: a basic set with arrow keys, tabs with icons, a sliding indicator bar, a list that becomes a scrolling row on narrow screens, tabs as descriptive cards, numbered steps and tabs that show counts.
Tabs stacked on the left with panels on the right, following the ARIA tabs pattern with aria-orientation="vertical", so Up and Down arrows move between tabs. Only the selected tab is in the tab order (roving tabindex), and Tab moves into the panel.
var tabs = document.querySelectorAll('.vtb1 [role=tab]');
function sel(t) { tabs.forEach(function (x) { var on = x === t; x.setAttribute('aria-selected', on); x.tabIndex = on ? 0 : -1; document.getElementById(x.getAttribute('aria-controls')).hidden = !on; }); t.focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(t); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel(tabs[(i + k + tabs.length) % tabs.length]); } if (e.key === 'Home') { e.preventDefault(); sel(tabs[0]); } if (e.key === 'End') { e.preventDefault(); sel(tabs[tabs.length - 1]); } }; });
02. Vertical Tabs with Icons
TabsIcons
Each tab pairs an icon with its label, which helps scanning in settings screens. The icons are aria-hidden and the label is the tab's name, and the selected tab gets a filled background plus a stronger text colour.
View code
<div class="vtb2"><div role="tablist" aria-orientation="vertical" aria-label="Settings"><button role="tab" aria-selected="true" aria-controls="vtb2p"><span aria-hidden="true">👤</span>General</button><button role="tab" aria-selected="false" aria-controls="vtb2p" tabindex="-1"><span aria-hidden="true">🔔</span>Alerts</button><button role="tab" aria-selected="false" aria-controls="vtb2p" tabindex="-1"><span aria-hidden="true">🔒</span>Privacy</button></div><div role="tabpanel" id="vtb2p" tabindex="0" aria-label="General">Language, time zone and theme.</div></div>
var tabs = document.querySelectorAll('.vtb2 [role=tab]'), panel = document.getElementById('vtb2p'), txt = ['Language, time zone and theme.', 'Email and push alerts.', 'Who can see your profile.'];
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); panel.textContent = txt[i]; panel.setAttribute('aria-label', tabs[i].textContent); tabs[i].focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
03. Sliding Indicator Bar
TabsIndicator
A coloured bar beside the tab list slides to the selected tab. Its position comes from a custom property set to the tab's index, and calc() turns that into a translateY, so the movement is a cheap transform.
var list = document.querySelector('.vtb3 .list'), tabs = list.querySelectorAll('[role=tab]'), panel = document.getElementById('vtb3p'), txt = ['What the library does.', 'npm install, then import.', 'Add the class to a list.'];
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); list.style.setProperty('--i', i); panel.textContent = txt[i]; panel.setAttribute('aria-label', tabs[i].textContent); tabs[i].focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
04. Vertical on Desktop, Scrolling Row on Mobile
TabsResponsive
The tab list sits on the left when there is room and turns into a horizontal scrolling row above the panel when there is not. A container query switches the layout, and aria-orientation is updated by script so arrow keys match the direction.
var w = document.querySelector('.vtb4'), list = w.querySelector('[role=tablist]'), tabs = list.querySelectorAll('[role=tab]'), panel = document.getElementById('vtb4p');
function vertical() { return getComputedStyle(list).flexDirection === 'column'; }
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); panel.textContent = tabs[i].textContent + ' for this month.'; panel.setAttribute('aria-label', tabs[i].textContent); tabs[i].focus(); }
new ResizeObserver(function () { list.setAttribute('aria-orientation', vertical() ? 'vertical' : 'horizontal'); }).observe(w);
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var map = vertical() ? { ArrowDown: 1, ArrowUp: -1 } : { ArrowRight: 1, ArrowLeft: -1 }, k = map[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
05. Tabs as Descriptive Cards
TabsCards
Each tab is a small card with a title and one line of description, useful when the options need explaining, such as plan types. The description is part of the tab's accessible name, and the selected card gets a coloured border.
var tabs = document.querySelectorAll('.vtb5 [role=tab]'), panel = document.getElementById('vtb5p'), txt = ['Free on orders over $50. Tracked.', '$9. Order before 2pm for next-day delivery.'];
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); panel.textContent = txt[i]; panel.setAttribute('aria-label', tabs[i].querySelector('b').textContent); tabs[i].focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
06. Numbered Step Tabs
TabsSteps
A how-it-works section where vertical tabs are numbered steps, and choosing one shows its detail. Numbers come from a CSS counter, so reordering the steps in the HTML renumbers them automatically.
View code
<div class="vtb6"><div role="tablist" aria-orientation="vertical" aria-label="How it works"><button role="tab" aria-selected="true" aria-controls="vtb6p">Connect your store</button><button role="tab" aria-selected="false" aria-controls="vtb6p" tabindex="-1">Choose a template</button><button role="tab" aria-selected="false" aria-controls="vtb6p" tabindex="-1">Go live</button></div><div role="tabpanel" id="vtb6p" tabindex="0" aria-label="Connect your store">Sign in with your shop platform. Products import in minutes.</div></div>
var tabs = document.querySelectorAll('.vtb6 [role=tab]'), panel = document.getElementById('vtb6p'), txt = ['Sign in with your shop platform. Products import in minutes.', 'Pick a design and adjust colours and fonts.', 'Connect your domain and publish.'];
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); panel.textContent = txt[i]; panel.setAttribute('aria-label', tabs[i].textContent); tabs[i].focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
07. Vertical Tabs with Counts
TabsBadges
Tabs for filtering a support queue, each showing how many items it holds. The count is inside the tab's text, read as Open 14, and the pill is pushed to the right with margin-left: auto so the labels stay aligned.
var tabs = document.querySelectorAll('.vtb7 [role=tab]'), panel = document.getElementById('vtb7p');
function sel(i) { tabs.forEach(function (x, j) { x.setAttribute('aria-selected', i === j); x.tabIndex = i === j ? 0 : -1; }); var name = tabs[i].firstChild.textContent.trim(), n = tabs[i].querySelector('em').textContent; panel.textContent = n + ' ' + name.toLowerCase() + ' tickets.'; panel.setAttribute('aria-label', name); tabs[i].focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(i); }; t.onkeydown = function (e) { var k = { ArrowDown: 1, ArrowUp: -1 }[e.key]; if (k) { e.preventDefault(); sel((i + k + tabs.length) % tabs.length); } }; });
About these CSS Vertical Tabs
Say it is vertical
Add aria-orientation="vertical" to the tablist and handle Up and Down arrows. If the layout switches to a row on phones, switch the orientation and the arrow keys with it.
Roving tabindex
Only the selected tab has tabindex="0"; the others have -1. Tab moves from the tab list into the panel, and arrow keys move between tabs, which keeps the Tab key fast.
Connect tabs and panels
Each tab points to its panel with aria-controls and each panel names itself with aria-labelledby or a label, so the panel is announced with the tab's name.
Tabs or navigation?
If each choice loads a different page with its own URL, it is navigation, not tabs. Use links with aria-current instead of the tabs pattern.
Frequently asked questions
When are vertical tabs better than horizontal ones?
When there are many tabs or long labels, such as settings pages.
Should tabs switch on focus or on Enter?
Automatic activation on arrow keys is fine when panels load instantly, as here.
Can the panel content be links and forms?
Yes. Give the panel tabindex=0 only when it has no focusable content of its own.