SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/vertical-tabs

7 CSS Vertical Tabs

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.

01. Accessible Vertical Tabs

TabsVertical

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.

View code
<div class="vtb1"><div role="tablist" aria-orientation="vertical" aria-label="Account"><button role="tab" id="vtb1a" aria-selected="true" aria-controls="vtb1pa">Profile</button><button role="tab" id="vtb1b" aria-selected="false" aria-controls="vtb1pb" tabindex="-1">Password</button><button role="tab" id="vtb1c" aria-selected="false" aria-controls="vtb1pc" tabindex="-1">Billing</button></div><div role="tabpanel" id="vtb1pa" aria-labelledby="vtb1a" tabindex="0">Your name, photo and bio.</div><div role="tabpanel" id="vtb1pb" aria-labelledby="vtb1b" tabindex="0" hidden>Change your password.</div><div role="tabpanel" id="vtb1pc" aria-labelledby="vtb1c" tabindex="0" hidden>Plan and invoices.</div></div>
.vtb1 { display: grid; grid-template-columns: 120px 1fr; gap: 12px; width: 420px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb1 [role=tablist] { display: grid; gap: 4px; align-content: start; }
.vtb1 [role=tab] { padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: #475467; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.vtb1 [aria-selected=true] { background: #f4ebff; color: #53389e; }
.vtb1 [role=tabpanel] { padding: 14px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; color: #344054; }
.vtb1 :focus-visible { outline: 3px solid #d6bbfb; outline-offset: 1px; }
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>
.vtb2 { display: grid; grid-template-columns: 130px 1fr; gap: 10px; width: 420px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb2 [role=tablist] { display: grid; gap: 4px; align-content: start; }
.vtb2 [role=tab] { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: #94a3b8; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.vtb2 [aria-selected=true] { background: #1e2546; color: #fff; }
.vtb2 [role=tabpanel] { padding: 14px; border-radius: 12px; background: #151b2e; color: #cbd5e1; }
.vtb2 :focus-visible { outline: 2px solid #a5b4fc; outline-offset: 1px; }
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.

View code
<div class="vtb3"><div class="list" role="tablist" aria-orientation="vertical" aria-label="Docs" style="--i:0"><span class="ind" aria-hidden="true"></span><button role="tab" aria-selected="true" aria-controls="vtb3p">Overview</button><button role="tab" aria-selected="false" aria-controls="vtb3p" tabindex="-1">Install</button><button role="tab" aria-selected="false" aria-controls="vtb3p" tabindex="-1">Usage</button></div><div role="tabpanel" id="vtb3p" tabindex="0" aria-label="Overview">What the library does.</div></div>
.vtb3 { display: grid; grid-template-columns: 120px 1fr; gap: 14px; width: 420px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb3 .list { position: relative; display: grid; align-content: start; padding-left: 10px; border-left: 2px solid #eaecf0; }
.vtb3 .ind { position: absolute; left: -2px; top: 0; width: 2px; height: 38px; background: #1d4ed8; transform: translateY(calc(var(--i) * 38px)); transition: transform .25s ease; }
.vtb3 [role=tab] { height: 38px; padding: 0 6px; border: 0; background: none; color: #475467; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.vtb3 [aria-selected=true] { color: #1d4ed8; }
.vtb3 [role=tabpanel] { padding: 14px; border-radius: 12px; background: #eff8ff; color: #1e3a8a; }
.vtb3 :focus-visible { outline: 3px solid #84adff; outline-offset: 1px; }
@media (prefers-reduced-motion: reduce) { .vtb3 .ind { transition: none; } }
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.

View code
<div class="vtb4"><div class="in"><div role="tablist" aria-orientation="vertical" aria-label="Report"><button role="tab" aria-selected="true" aria-controls="vtb4p">Summary</button><button role="tab" aria-selected="false" aria-controls="vtb4p" tabindex="-1">Traffic</button><button role="tab" aria-selected="false" aria-controls="vtb4p" tabindex="-1">Sales</button><button role="tab" aria-selected="false" aria-controls="vtb4p" tabindex="-1">Customers</button></div><div role="tabpanel" id="vtb4p" tabindex="0" aria-label="Summary">Revenue up 12% this month.</div></div></div>
.vtb4 { width: 460px; max-width: 100%; container-type: inline-size; font: 14px Inter, system-ui, sans-serif; }
.vtb4 .in { display: grid; gap: 10px; }
.vtb4 [role=tablist] { display: flex; gap: 4px; overflow-x: auto; }
.vtb4 [role=tab] { flex: none; padding: 8px 12px; border: 1px solid #334155; border-radius: 999px; background: none; color: #94a3b8; font: 600 13px Inter, system-ui, sans-serif; cursor: pointer; }
.vtb4 [aria-selected=true] { background: #f8fafc; color: #0f172a; border-color: #f8fafc; }
.vtb4 [role=tabpanel] { padding: 14px; border-radius: 12px; background: #151b2e; color: #cbd5e1; }
.vtb4 :focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
@container (min-width: 400px) { .vtb4 .in { grid-template-columns: 130px 1fr; } .vtb4 [role=tablist] { flex-direction: column; overflow: visible; } .vtb4 [role=tab] { border-radius: 8px; text-align: left; } }
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.

View code
<div class="vtb5"><div role="tablist" aria-orientation="vertical" aria-label="Delivery"><button role="tab" aria-selected="true" aria-controls="vtb5p"><b>Standard</b><span>3-5 days</span></button><button role="tab" aria-selected="false" aria-controls="vtb5p" tabindex="-1"><b>Express</b><span>Next day</span></button></div><div role="tabpanel" id="vtb5p" tabindex="0" aria-label="Standard">Free on orders over $50. Tracked.</div></div>
.vtb5 { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; width: 420px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb5 [role=tablist] { display: grid; gap: 6px; align-content: start; }
.vtb5 [role=tab] { display: grid; gap: 2px; padding: 12px; border: 1px solid #d0d5dd; border-radius: 12px; background: #fff; text-align: left; cursor: pointer; font: inherit; }
.vtb5 b { color: #101828; } .vtb5 span { color: #475467; font-size: 13px; }
.vtb5 [aria-selected=true] { border-color: #079455; box-shadow: 0 0 0 1px #079455; background: #f6fef9; }
.vtb5 [role=tabpanel] { padding: 14px; border-radius: 12px; background: #f9fafb; color: #344054; }
.vtb5 :focus-visible { outline: 3px solid #75e0a7; outline-offset: 2px; }
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>
.vtb6 { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; width: 440px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb6 [role=tablist] { display: grid; gap: 6px; align-content: start; counter-reset: s; }
.vtb6 [role=tab] { display: flex; align-items: center; gap: 10px; padding: 10px; border: 0; border-radius: 10px; background: #151b2e; color: #94a3b8; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; counter-increment: s; }
.vtb6 [role=tab]::before { content: counter(s); display: grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: #263048; color: #e2e8f0; font-size: 13px; }
.vtb6 [aria-selected=true] { background: #1e2546; color: #fff; } .vtb6 [aria-selected=true]::before { background: #f59e0b; color: #1c1917; }
.vtb6 [role=tabpanel] { padding: 14px; border-radius: 12px; background: #111727; border: 1px solid #263048; color: #cbd5e1; }
.vtb6 :focus-visible { outline: 2px solid #fbbf24; outline-offset: 1px; }
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.

View code
<div class="vtb7"><div role="tablist" aria-orientation="vertical" aria-label="Tickets"><button role="tab" aria-selected="true" aria-controls="vtb7p">Open <em>14</em></button><button role="tab" aria-selected="false" aria-controls="vtb7p" tabindex="-1">Pending <em>3</em></button><button role="tab" aria-selected="false" aria-controls="vtb7p" tabindex="-1">Closed <em>128</em></button></div><div role="tabpanel" id="vtb7p" tabindex="0" aria-label="Open">14 open tickets.</div></div>
.vtb7 { display: grid; grid-template-columns: 140px 1fr; gap: 12px; width: 420px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vtb7 [role=tablist] { display: grid; gap: 2px; align-content: start; }
.vtb7 [role=tab] { display: flex; align-items: center; padding: 8px 10px; border: 0; border-radius: 8px; background: none; color: #344054; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.vtb7 em { margin-left: auto; padding: 1px 7px; border-radius: 999px; background: #eaecf0; font-size: 12px; font-style: normal; }
.vtb7 [aria-selected=true] { background: #101828; color: #fff; } .vtb7 [aria-selected=true] em { background: #344054; }
.vtb7 [role=tabpanel] { padding: 14px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; color: #344054; }
.vtb7 :focus-visible { outline: 3px solid #84adff; outline-offset: 1px; }
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.