UI Components Easy
Tabs Vertical
Vertical and horizontal tab panels with animated sliding indicator, keyboard navigation, and lazy panel rendering. No dependencies.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--accent: #818cf8;
--accent-bg: rgba(129, 140, 248, 0.12);
--radius: 10px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 40px 24px;
}
.demo-wrap {
max-width: 900px;
margin: 0 auto;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.demo-header h1 {
font-size: 1.4rem;
font-weight: 700;
}
.orientation-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.82rem;
font-weight: 500;
padding: 7px 14px;
border-radius: 8px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.orientation-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ── Container: Vertical (default) ── */
.tabs-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
}
/* ── Tablist container ── */
.tablist-wrap {
position: relative;
display: flex;
}
/* Vertical */
.tabs-container[data-orient="vertical"] .tablist-wrap {
flex-direction: column;
border-right: 1px solid var(--border);
min-width: 200px;
}
/* Horizontal */
.tabs-container[data-orient="horizontal"] {
flex-direction: column;
}
.tabs-container[data-orient="horizontal"] .tablist-wrap {
flex-direction: row;
border-right: none;
border-bottom: 1px solid var(--border);
}
/* ── Tablist ── */
.tablist {
display: flex;
flex-direction: column;
padding: 8px;
gap: 2px;
flex: 1;
}
.tabs-container[data-orient="horizontal"] .tablist {
flex-direction: row;
overflow-x: auto;
}
/* ── Tab button ── */
.tab {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 12px;
background: none;
border: none;
border-radius: 7px;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition: color .15s, background .15s;
position: relative;
z-index: 1;
}
.tab:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.tab.active {
color: var(--accent);
background: var(--accent-bg);
}
.tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tab-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: grid;
place-items: center;
}
.tab-text {
flex: 1;
}
.tab-badge {
font-size: 0.65rem;
font-weight: 700;
background: var(--accent);
color: #fff;
padding: 1px 6px;
border-radius: 999px;
line-height: 1.5;
}
/* Animated indicator */
.tab-indicator {
position: absolute;
background: var(--accent);
border-radius: 4px;
transition: top .25s cubic-bezier(0.4, 0, 0.2, 1), left .25s cubic-bezier(0.4, 0, 0.2, 1), width
.25s cubic-bezier(0.4, 0, 0.2, 1), height .25s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
/* Vertical: indicator on the right edge */
.tabs-container[data-orient="vertical"] .tab-indicator {
right: 0;
width: 3px;
border-radius: 3px 0 0 3px;
}
/* Horizontal: indicator on bottom edge */
.tabs-container[data-orient="horizontal"] .tab-indicator {
bottom: 0;
height: 2px;
width: auto;
border-radius: 2px 2px 0 0;
}
/* ── Panels ── */
.panels {
flex: 1;
padding: 28px 24px;
}
.panel {
display: none;
animation: panelIn .2s ease;
}
.panel.active {
display: block;
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.panel h2 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 6px;
}
.panel p {
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.6;
margin-bottom: 20px;
}
.panel-form {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 380px;
}
.panel-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
}
.panel-form input,
.panel-form textarea {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--text);
font-size: 0.875rem;
padding: 8px 12px;
outline: none;
font-family: inherit;
resize: vertical;
transition: border-color .15s;
}
.panel-form input:focus,
.panel-form textarea:focus {
border-color: var(--accent);
}
.notif-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.notif-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
cursor: pointer;
color: var(--text);
}
.notif-row input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
.plan-chip {
display: inline-flex;
align-items: center;
background: var(--accent-bg);
border: 1px solid rgba(129, 140, 248, 0.3);
color: var(--accent);
font-size: 0.875rem;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
}const container = document.getElementById("tabsContainer");
const tablist = document.getElementById("tablist");
const indicator = document.getElementById("tabIndicator");
const orientToggle = document.getElementById("orientToggle");
let orient = "vertical";
// Activate a tab
function activateTab(tab) {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((t) => {
t.classList.remove("active");
t.setAttribute("aria-selected", "false");
t.tabIndex = -1;
});
panels.forEach((p) => {
p.classList.remove("active");
p.hidden = true;
});
tab.classList.add("active");
tab.setAttribute("aria-selected", "true");
tab.tabIndex = 0;
const panel = document.getElementById(tab.getAttribute("aria-controls"));
if (panel) {
panel.classList.add("active");
panel.hidden = false;
}
moveIndicator(tab);
}
// Move indicator to active tab
function moveIndicator(tab) {
if (!tab) return;
const tRect = tab.getBoundingClientRect();
const wRect = tablist.parentElement.getBoundingClientRect();
if (orient === "vertical") {
indicator.style.top = tRect.top - wRect.top + tablist.parentElement.scrollTop + "px";
indicator.style.height = tRect.height + "px";
indicator.style.left = "";
indicator.style.width = "3px";
} else {
indicator.style.left = tRect.left - wRect.left + tablist.scrollLeft + "px";
indicator.style.width = tRect.width + "px";
indicator.style.top = "";
indicator.style.height = "2px";
}
}
// Click
tablist.addEventListener("click", (e) => {
const tab = e.target.closest('[role="tab"]');
if (tab) activateTab(tab);
});
// Keyboard
tablist.addEventListener("keydown", (e) => {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const idx = tabs.indexOf(document.activeElement);
let next;
if (orient === "vertical") {
if (e.key === "ArrowDown") next = tabs[(idx + 1) % tabs.length];
if (e.key === "ArrowUp") next = tabs[(idx - 1 + tabs.length) % tabs.length];
} else {
if (e.key === "ArrowRight") next = tabs[(idx + 1) % tabs.length];
if (e.key === "ArrowLeft") next = tabs[(idx - 1 + tabs.length) % tabs.length];
}
if (e.key === "Home") next = tabs[0];
if (e.key === "End") next = tabs[tabs.length - 1];
if (next) {
e.preventDefault();
next.focus();
activateTab(next);
}
});
// Orientation toggle
orientToggle?.addEventListener("click", () => {
orient = orient === "vertical" ? "horizontal" : "vertical";
container.dataset.orient = orient;
orientToggle.textContent = orient === "vertical" ? "Switch to Horizontal" : "Switch to Vertical";
// Reposition indicator after layout shift
requestAnimationFrame(() => {
const active = tablist.querySelector(".active");
if (active) moveIndicator(active);
});
});
// Init
const firstTab = tablist.querySelector('[role="tab"]');
if (firstTab) activateTab(firstTab);
// Reposition on resize
window.addEventListener("resize", () => {
const active = tablist.querySelector(".active");
if (active) moveIndicator(active);
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tabs Vertical</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo-wrap">
<div class="demo-header">
<h1>Tabs — Vertical & Horizontal</h1>
<button class="orientation-btn" id="orientToggle">Switch to Horizontal</button>
</div>
<!-- ── Tab component ── -->
<div class="tabs-container" id="tabsContainer" data-orient="vertical">
<!-- Tab list (vertical = sidebar, horizontal = top bar) -->
<div class="tablist-wrap">
<div role="tablist" aria-label="Settings tabs" class="tablist" id="tablist">
<button role="tab" class="tab active" id="tab-profile" aria-selected="true"
aria-controls="panel-profile" tabindex="0">
<span class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="tab-text">Profile</span>
</button>
<button role="tab" class="tab" id="tab-account" aria-selected="false" aria-controls="panel-account"
tabindex="-1">
<span class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 7V5a2 2 0 0 0-4 0v2M8 11h.01M16 11h.01" />
</svg>
</span>
<span class="tab-text">Account</span>
<span class="tab-badge">2</span>
</button>
<button role="tab" class="tab" id="tab-security" aria-selected="false"
aria-controls="panel-security" tabindex="-1">
<span class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
<span class="tab-text">Security</span>
</button>
<button role="tab" class="tab" id="tab-notifications" aria-selected="false"
aria-controls="panel-notifications" tabindex="-1">
<span class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</span>
<span class="tab-text">Notifications</span>
</button>
<button role="tab" class="tab" id="tab-billing" aria-selected="false" aria-controls="panel-billing"
tabindex="-1">
<span class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</span>
<span class="tab-text">Billing</span>
</button>
</div>
<!-- Active indicator -->
<span class="tab-indicator" id="tabIndicator" aria-hidden="true"></span>
</div>
<!-- Panels -->
<div class="panels">
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile" class="panel active">
<h2>Profile Settings</h2>
<p>Update your display name, avatar, and bio. These details are visible to other members.</p>
<div class="panel-form">
<label>Display Name <input type="text" value="Alex Chen" /></label>
<label>Bio <textarea rows="3">Frontend engineer at Stealthis.</textarea></label>
</div>
</div>
<div role="tabpanel" id="panel-account" aria-labelledby="tab-account" class="panel" hidden>
<h2>Account Details</h2>
<p>Manage your email address, username, and account preferences.</p>
<div class="panel-form">
<label>Email <input type="email" value="alex@stealthis.dev" /></label>
<label>Username <input type="text" value="@alexchen" /></label>
</div>
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" class="panel" hidden>
<h2>Security</h2>
<p>Manage your password, two-factor authentication, and active sessions.</p>
<div class="panel-form">
<label>Current Password <input type="password" placeholder="••••••••" /></label>
<label>New Password <input type="password" placeholder="••••••••" /></label>
</div>
</div>
<div role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications" class="panel" hidden>
<h2>Notifications</h2>
<p>Choose when and how you receive notifications from the platform.</p>
<div class="notif-options">
<label class="notif-row"><input type="checkbox" checked /> Email for new messages</label>
<label class="notif-row"><input type="checkbox" /> Push notifications</label>
<label class="notif-row"><input type="checkbox" checked /> Weekly digest</label>
</div>
</div>
<div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" class="panel" hidden>
<h2>Billing</h2>
<p>View your current plan, manage payment methods, and download invoices.</p>
<div class="plan-chip">Pro Plan · $29 / month</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Tabs Vertical
A flexible tab component supporting both vertical (sidebar) and horizontal (top bar) orientations, with a smooth animated active indicator, full keyboard navigation, and lazy panel activation.
Features
- Vertical sidebar layout and horizontal top-bar layout in one component
- Animated pill/indicator that slides between tabs
- Full keyboard navigation:
Arrowkeys,Home,End role="tablist",role="tab",role="tabpanel"ARIA structure- Badge count support on tab labels
- Orientation toggle button to switch layouts dynamically
How it works
- Active indicator is an absolutely-positioned element; JS measures the active tab and moves it via
style.top/style.left aria-selected,tabindex, andaria-controlsare set on activation- Arrow key listeners cycle through
[role="tab"]elements respecting orientation