Open demo

CSS code

HTML
<div class="vtabs">
  <div role="tablist" aria-label="Account settings" aria-orientation="vertical">
    <button type="button" role="tab" id="vt-tab-0" aria-selected="true" aria-controls="vt-panel-0"><span aria-hidden="true">πŸ‘€</span> Profile</button>
    <button type="button" role="tab" id="vt-tab-1" aria-selected="false" aria-controls="vt-panel-1" tabindex="-1"><span aria-hidden="true">πŸ””</span> Notifications</button>
    <button type="button" role="tab" id="vt-tab-2" aria-selected="false" aria-controls="vt-panel-2" tabindex="-1"><span aria-hidden="true">πŸ”’</span> Security</button>
    <button type="button" role="tab" id="vt-tab-3" aria-selected="false" aria-controls="vt-panel-3" tabindex="-1"><span aria-hidden="true">πŸ’³</span> Billing</button>
  </div>
  <div role="tabpanel" id="vt-panel-0" aria-labelledby="vt-tab-0" tabindex="0"><h2>Profile</h2><p>Vertical tabs suit settings screens: the labels read as a menu, and long panel content scrolls independently. On phones the rail flips horizontal automatically.</p></div>
  <div role="tabpanel" id="vt-panel-1" aria-labelledby="vt-tab-1" tabindex="0" hidden><h2>Notifications</h2><p>Choose what lands in your inbox and what stays in-app. The active tab carries a colored spine so orientation stays obvious.</p></div>
  <div role="tabpanel" id="vt-panel-2" aria-labelledby="vt-tab-2" tabindex="0" hidden><h2>Security</h2><p>Two-factor settings, sessions, and device history would live here β€” the panel is just markup, put anything in it.</p></div>
  <div role="tabpanel" id="vt-panel-3" aria-labelledby="vt-tab-3" tabindex="0" hidden><h2>Billing</h2><p>Plan, invoices, and payment methods. The whole component is one grid, the tab markup, and a short script.</p></div>
</div>
CSS
/* Vertical tabs: a side rail of tabs next to one panel. Under 560px the rail turns into a horizontal, scrollable row. */
.vtabs{--vt-accent:#0f766e;--vt-muted:#5c6a82;--vt-text:#182130;
  box-sizing:border-box;display:grid;grid-template-columns:200px 1fr;width:100%;max-width:640px;min-height:280px;background:#fff;
  border:1px solid #e5e9ef;border-radius:16px;overflow:hidden;box-shadow:0 14px 34px -20px rgba(18,28,48,.16);color:var(--vt-text)}
.vtabs [role=tablist]{background:#f8fafc;border-right:1px solid #edf0f5;padding:14px;display:flex;flex-direction:column;gap:4px}
.vtabs [role=tab]{display:flex;align-items:center;gap:10px;margin:0;border:0;border-left:3px solid transparent;background:none;text-align:left;
  padding:11px 12px;border-radius:10px;font:inherit;font-size:.87rem;font-weight:600;color:var(--vt-muted);cursor:pointer;
  transition:background-color .15s,color .15s,border-color .15s,box-shadow .15s}
.vtabs [role=tab]:hover{background:#eef2f7;color:var(--vt-text)}
.vtabs [role=tab][aria-selected=true]{background:#fff;color:var(--vt-accent);border-left-color:var(--vt-accent);box-shadow:0 4px 12px -6px rgba(15,23,42,.15)}
.vtabs [role=tabpanel]{padding:26px 28px}
.vtabs [role=tabpanel] h2{margin:0 0 8px;font-size:1.05rem}
.vtabs [role=tabpanel] p{margin:0;font-size:.88rem;line-height:1.7;color:var(--vt-muted)}
.vtabs [role=tab]:focus-visible{outline:2px solid var(--vt-accent);outline-offset:-2px}
.vtabs [role=tabpanel]:focus-visible{outline:2px solid var(--vt-accent);outline-offset:-6px;border-radius:14px}
.vtabs [hidden]{display:none}
@media (max-width:560px){
  .vtabs{grid-template-columns:1fr}
  .vtabs [role=tablist]{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid #edf0f5}
  .vtabs [role=tab]{border-left:0;white-space:nowrap}
}
@media (prefers-reduced-motion:reduce){.vtabs [role=tab]{transition:none}}
JavaScript
/* WAI-ARIA tabs: click or use the arrow keys to select (Up/Down on the rail, Left/Right when it is a row),
   Home/End jump to the first/last tab. Only the selected tab is in the Tab order (roving tabindex). */
document.querySelectorAll('.vtabs').forEach(root => {
  const list = root.querySelector('[role=tablist]');
  const tabs = [...list.querySelectorAll('[role=tab]')];
  const select = (tab, focus) => {
    tabs.forEach(t => {
      const on = t === tab;
      t.setAttribute('aria-selected', on);
      t.tabIndex = on ? 0 : -1;
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    });
    if (focus) tab.focus();
  };
  list.addEventListener('click', e => { const t = e.target.closest('[role=tab]'); if (t) select(t); });
  list.addEventListener('keydown', e => {
    const i = tabs.indexOf(e.target), n = tabs.length;
    const j = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
    if (i < 0 || j === undefined) return;
    e.preventDefault();
    select(tabs[j % n], true);
  });
  // keep aria-orientation true to the layout
  const narrow = matchMedia('(max-width: 560px)');
  const orient = () => list.setAttribute('aria-orientation', narrow.matches ? 'horizontal' : 'vertical');
  narrow.addEventListener('change', orient); orient();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<div class="vtabs">
  <ul class="nav nav-pills" role="tablist" aria-label="Account settings" aria-orientation="vertical">
    <li class="nav-item" role="presentation"><button class="nav-link active" type="button" id="vt-tab-0" data-bs-toggle="pill" data-bs-target="#vt-panel-0" role="tab" aria-controls="vt-panel-0" aria-selected="true"><span aria-hidden="true">πŸ‘€</span> Profile</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="vt-tab-1" data-bs-toggle="pill" data-bs-target="#vt-panel-1" role="tab" aria-controls="vt-panel-1" aria-selected="false" tabindex="-1"><span aria-hidden="true">πŸ””</span> Notifications</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="vt-tab-2" data-bs-toggle="pill" data-bs-target="#vt-panel-2" role="tab" aria-controls="vt-panel-2" aria-selected="false" tabindex="-1"><span aria-hidden="true">πŸ”’</span> Security</button></li>
    <li class="nav-item" role="presentation"><button class="nav-link" type="button" id="vt-tab-3" data-bs-toggle="pill" data-bs-target="#vt-panel-3" role="tab" aria-controls="vt-panel-3" aria-selected="false" tabindex="-1"><span aria-hidden="true">πŸ’³</span> Billing</button></li>
  </ul>
  <div class="tab-content">
    <div class="tab-pane active" id="vt-panel-0" role="tabpanel" aria-labelledby="vt-tab-0" tabindex="0"><h2>Profile</h2><p>Vertical tabs suit settings screens: the labels read as a menu, and long panel content scrolls independently. On phones the rail flips horizontal automatically.</p></div>
    <div class="tab-pane" id="vt-panel-1" role="tabpanel" aria-labelledby="vt-tab-1" tabindex="0"><h2>Notifications</h2><p>Choose what lands in your inbox and what stays in-app. The active tab carries a colored spine so orientation stays obvious.</p></div>
    <div class="tab-pane" id="vt-panel-2" role="tabpanel" aria-labelledby="vt-tab-2" tabindex="0"><h2>Security</h2><p>Two-factor settings, sessions, and device history would live here β€” the panel is just markup, put anything in it.</p></div>
    <div class="tab-pane" id="vt-panel-3" role="tabpanel" aria-labelledby="vt-tab-3" tabindex="0"><h2>Billing</h2><p>Plan, invoices, and payment methods. The whole component is one grid, the tab markup, and a short script.</p></div>
  </div>
</div>
CSS
/* Bootstrap vertical .nav-pills with the Tab plugin, restyled through the --bs-nav-* variables. Under 560px the rail becomes a scrollable row. */
.vtabs{--vt-accent:#0f766e;--vt-muted:#5c6a82;--vt-text:#182130;
  display:grid;grid-template-columns:200px 1fr;width:100%;max-width:640px;min-height:280px;background:#fff;
  border:1px solid #e5e9ef;border-radius:16px;overflow:hidden;box-shadow:0 14px 34px -20px rgba(18,28,48,.16);color:var(--vt-text)}
.vtabs .nav-pills{--bs-nav-link-padding-x:12px;--bs-nav-link-padding-y:11px;--bs-nav-link-font-size:.87rem;--bs-nav-link-font-weight:600;
  --bs-nav-link-color:var(--vt-muted);--bs-nav-link-hover-color:var(--vt-text);
  --bs-nav-pills-border-radius:10px;--bs-nav-pills-link-active-bg:#fff;--bs-nav-pills-link-active-color:var(--vt-accent);
  flex-direction:column;flex-wrap:nowrap;gap:4px;background:#f8fafc;border-right:1px solid #edf0f5;padding:14px}
.vtabs .nav-link{display:flex;align-items:center;gap:10px;width:100%;text-align:left;line-height:normal;border-left:3px solid transparent;
  transition:background-color .15s,color .15s,border-color .15s,box-shadow .15s}
.vtabs .nav-link:hover{background:#eef2f7}
.vtabs .nav-link.active{border-left-color:var(--vt-accent);box-shadow:0 4px 12px -6px rgba(15,23,42,.15)}
.vtabs .tab-pane{padding:26px 28px}
.vtabs .tab-pane h2{margin:0 0 8px;font-size:1.05rem;font-weight:700;line-height:normal}
.vtabs .tab-pane p{margin:0;font-size:.88rem;line-height:1.7;color:var(--vt-muted)}
.vtabs .nav-link:focus-visible{box-shadow:none;outline:2px solid var(--vt-accent);outline-offset:-2px}
.vtabs .tab-pane:focus-visible{outline:2px solid var(--vt-accent);outline-offset:-6px;border-radius:14px}
@media (max-width:560px){
  .vtabs{grid-template-columns:1fr}
  .vtabs .nav-pills{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid #edf0f5}
  .vtabs .nav-link{border-left:0;white-space:nowrap}
}
@media (prefers-reduced-motion:reduce){.vtabs .nav-link{transition:none}}
JavaScript
/* The Tab plugin handles selection, all four arrow keys, Home/End and roving tabindex.
   This only keeps aria-orientation true to the layout. */
document.querySelectorAll('.vtabs .nav').forEach(list => {
  const narrow = matchMedia('(max-width: 560px)');
  const orient = () => list.setAttribute('aria-orientation', narrow.matches ? 'horizontal' : 'vertical');
  narrow.addEventListener('change', orient); orient();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="vtabs grid min-h-[280px] w-full max-w-[640px] grid-cols-[200px_1fr] overflow-hidden rounded-2xl border border-[#e5e9ef] bg-white text-vt-text shadow-[0_14px_34px_-20px_rgba(18,28,48,.16)] max-[561px]:grid-cols-1">
  <div role="tablist" aria-label="Account settings" aria-orientation="vertical" class="flex flex-col gap-1 border-r border-[#edf0f5] bg-[#f8fafc] p-3.5 max-[561px]:flex-row max-[561px]:overflow-x-auto max-[561px]:border-r-0 max-[561px]:border-b">
    <button type="button" role="tab" id="vt-tab-0" aria-selected="true" aria-controls="vt-panel-0" class="flex cursor-pointer items-center gap-2.5 rounded-[10px] border-l-3 border-transparent px-3 py-[11px] text-left text-[.87rem] font-semibold text-vt-muted transition-[background-color,color,border-color,box-shadow] duration-150 hover:bg-[#eef2f7] hover:text-vt-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-vt-accent aria-selected:border-vt-accent aria-selected:bg-white aria-selected:text-vt-accent aria-selected:shadow-[0_4px_12px_-6px_rgba(15,23,42,.15)] motion-reduce:transition-none max-[561px]:border-l-0 max-[561px]:whitespace-nowrap"><span aria-hidden="true">πŸ‘€</span> Profile</button>
    <button type="button" role="tab" id="vt-tab-1" aria-selected="false" aria-controls="vt-panel-1" tabindex="-1" class="flex cursor-pointer items-center gap-2.5 rounded-[10px] border-l-3 border-transparent px-3 py-[11px] text-left text-[.87rem] font-semibold text-vt-muted transition-[background-color,color,border-color,box-shadow] duration-150 hover:bg-[#eef2f7] hover:text-vt-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-vt-accent aria-selected:border-vt-accent aria-selected:bg-white aria-selected:text-vt-accent aria-selected:shadow-[0_4px_12px_-6px_rgba(15,23,42,.15)] motion-reduce:transition-none max-[561px]:border-l-0 max-[561px]:whitespace-nowrap"><span aria-hidden="true">πŸ””</span> Notifications</button>
    <button type="button" role="tab" id="vt-tab-2" aria-selected="false" aria-controls="vt-panel-2" tabindex="-1" class="flex cursor-pointer items-center gap-2.5 rounded-[10px] border-l-3 border-transparent px-3 py-[11px] text-left text-[.87rem] font-semibold text-vt-muted transition-[background-color,color,border-color,box-shadow] duration-150 hover:bg-[#eef2f7] hover:text-vt-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-vt-accent aria-selected:border-vt-accent aria-selected:bg-white aria-selected:text-vt-accent aria-selected:shadow-[0_4px_12px_-6px_rgba(15,23,42,.15)] motion-reduce:transition-none max-[561px]:border-l-0 max-[561px]:whitespace-nowrap"><span aria-hidden="true">πŸ”’</span> Security</button>
    <button type="button" role="tab" id="vt-tab-3" aria-selected="false" aria-controls="vt-panel-3" tabindex="-1" class="flex cursor-pointer items-center gap-2.5 rounded-[10px] border-l-3 border-transparent px-3 py-[11px] text-left text-[.87rem] font-semibold text-vt-muted transition-[background-color,color,border-color,box-shadow] duration-150 hover:bg-[#eef2f7] hover:text-vt-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-vt-accent aria-selected:border-vt-accent aria-selected:bg-white aria-selected:text-vt-accent aria-selected:shadow-[0_4px_12px_-6px_rgba(15,23,42,.15)] motion-reduce:transition-none max-[561px]:border-l-0 max-[561px]:whitespace-nowrap"><span aria-hidden="true">πŸ’³</span> Billing</button>
  </div>
  <div role="tabpanel" id="vt-panel-0" aria-labelledby="vt-tab-0" tabindex="0" class="px-7 py-[26px] focus-visible:rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-6 focus-visible:outline-vt-accent"><h2 class="mb-2 text-[1.05rem] font-bold">Profile</h2><p class="text-[.88rem] leading-[1.7] text-vt-muted">Vertical tabs suit settings screens: the labels read as a menu, and long panel content scrolls independently. On phones the rail flips horizontal automatically.</p></div>
  <div role="tabpanel" id="vt-panel-1" aria-labelledby="vt-tab-1" tabindex="0" hidden class="px-7 py-[26px] focus-visible:rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-6 focus-visible:outline-vt-accent"><h2 class="mb-2 text-[1.05rem] font-bold">Notifications</h2><p class="text-[.88rem] leading-[1.7] text-vt-muted">Choose what lands in your inbox and what stays in-app. The active tab carries a colored spine so orientation stays obvious.</p></div>
  <div role="tabpanel" id="vt-panel-2" aria-labelledby="vt-tab-2" tabindex="0" hidden class="px-7 py-[26px] focus-visible:rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-6 focus-visible:outline-vt-accent"><h2 class="mb-2 text-[1.05rem] font-bold">Security</h2><p class="text-[.88rem] leading-[1.7] text-vt-muted">Two-factor settings, sessions, and device history would live here β€” the panel is just markup, put anything in it.</p></div>
  <div role="tabpanel" id="vt-panel-3" aria-labelledby="vt-tab-3" tabindex="0" hidden class="px-7 py-[26px] focus-visible:rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-6 focus-visible:outline-vt-accent"><h2 class="mb-2 text-[1.05rem] font-bold">Billing</h2><p class="text-[.88rem] leading-[1.7] text-vt-muted">Plan, invoices, and payment methods. The whole component is one grid, the tab markup, and a short script.</p></div>
</div>
CSS
@theme {
  --color-vt-accent: #0f766e;
  --color-vt-muted: #5c6a82;
  --color-vt-text: #182130;
}
JavaScript
/* WAI-ARIA tabs: click or use the arrow keys to select (Up/Down on the rail, Left/Right when it is a row),
   Home/End jump to the first/last tab. Only the selected tab is in the Tab order (roving tabindex). */
document.querySelectorAll('.vtabs').forEach(root => {
  const list = root.querySelector('[role=tablist]');
  const tabs = [...list.querySelectorAll('[role=tab]')];
  const select = (tab, focus) => {
    tabs.forEach(t => {
      const on = t === tab;
      t.setAttribute('aria-selected', on);
      t.tabIndex = on ? 0 : -1;
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    });
    if (focus) tab.focus();
  };
  list.addEventListener('click', e => { const t = e.target.closest('[role=tab]'); if (t) select(t); });
  list.addEventListener('keydown', e => {
    const i = tabs.indexOf(e.target), n = tabs.length;
    const j = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1 + n, ArrowUp: i - 1 + n, Home: 0, End: n - 1 }[e.key];
    if (i < 0 || j === undefined) return;
    e.preventDefault();
    select(tabs[j % n], true);
  });
  // keep aria-orientation true to the layout
  const narrow = matchMedia('(max-width: 560px)');
  const orient = () => list.setAttribute('aria-orientation', narrow.matches ? 'horizontal' : 'vertical');
  narrow.addEventListener('change', orient); orient();
});

About this set

A settings card with a 200 pixel side rail of tabs, Profile, Notifications, Security and Billing, each with a small emoji icon, and the selected section’s heading and text in the panel on the right. The selected tab turns white with a teal spine on its left edge and a soft shadow, so it reads as lifted out of the grey rail. Use it for account settings, admin preferences and documentation sections where the labels read like a menu and the panels hold longer content. Below 560 pixels the grid collapses to one column and the rail becomes a horizontal row that scrolls sideways, so it stays usable on phones. The script handles selection, the roving tabindex and the hidden panels, and it also switches aria-orientation between vertical and horizontal as the layout changes, so assistive technology is told the truth. The Bootstrap version uses vertical .nav-pills with the Tab plugin, and the Tailwind version builds the rail and its states from grid, border-l and aria-selected utilities.

What’s included

  • Side rail with icons and a coloured spine on the selected tab
  • Collapses to a scrolling horizontal row under 560px
  • aria-orientation follows the layout
  • Emoji icons hidden from screen readers
  • Panels hold any markup, headings included

Accessibility

The rail is a labelled tablist whose tabs control panels labelled by those tabs, and the decorative emoji are aria-hidden so each tab is announced by its word. Only the selected tab is in the Tab order; Left and Right (or Up and Down) move between tabs and select them, and Home and End jump to the first and last tab. Focus shows a 2px teal outline on the tab or panel, and transitions are removed under prefers-reduced-motion.

Customise it

Set --vt-accent, --vt-muted and --vt-text on .vtabs (plain CSS and Bootstrap) and change grid-template-columns for a wider rail; Bootstrap also takes --bs-nav-pills-link-active-bg and the --bs-nav-link-* variables. In Tailwind edit the --color-vt-* theme colours and the grid-cols-[200px_1fr] utility.