Tabs
Organize content into switchable panels. Uses WAI-ARIA Tabs pattern with
roving tabindex and arrow key navigation. Available in pill and line variants.
Pill Variant (Default)
Default style with --muted background. Active tab gets --background fill and shadow.
Account
Make changes to your account here.
Password
Change your password here.
<div class="tabs">
<div class="tab-list" role="tablist" aria-label="Account settings">
<button class="tab-trigger" role="tab"
aria-selected="true"
aria-controls="pill-panel-account"
id="pill-tab-account">Account</button>
<button class="tab-trigger" role="tab"
aria-selected="false"
aria-controls="pill-panel-password"
id="pill-tab-password"
tabindex="-1">Password</button>
</div>
<div class="tab-content" role="tabpanel"
id="pill-panel-account"
aria-labelledby="pill-tab-account"
tabindex="0">
<div class="card" style="max-width:24rem;">
<div class="card-header">
<h3 class="card-title" style="font-size:1rem;">Account</h3>
<p class="card-description">Make changes to your account here.</p>
</div>
<div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label class="label" for="demo-tab-name">Name</label>
<input class="input" id="demo-tab-name" type="text" value="Cody Lindley">
</div>
<div>
<label class="label" for="demo-tab-user">Username</label>
<input class="input" id="demo-tab-user" type="text" value="@codylindley">
</div>
</div>
<div class="card-footer">
<button class="btn" data-variant="default">Save changes</button>
</div>
</div>
</div>
<div class="tab-content" role="tabpanel"
id="pill-panel-password"
aria-labelledby="pill-tab-password"
tabindex="0" hidden>
<div class="card" style="max-width:24rem;">
<div class="card-header">
<h3 class="card-title" style="font-size:1rem;">Password</h3>
<p class="card-description">Change your password here.</p>
</div>
<div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label class="label" for="demo-tab-current">Current password</label>
<input class="input" id="demo-tab-current" type="password">
</div>
<div>
<label class="label" for="demo-tab-new">New password</label>
<input class="input" id="demo-tab-new" type="password">
</div>
</div>
<div class="card-footer">
<button class="btn" data-variant="default">Save password</button>
</div>
</div>
</div>
</div>
Line Variant with Disabled Tab
Add data-variant="line" to the .tab-list for an underline style. Disabled tabs use the native disabled attribute.
Overview dashboard content goes here. This is the default active panel.
Analytics panel with charts and metrics.
Reports panel with exportable data tables.
<div class="tabs">
<div class="tab-list" role="tablist" aria-label="Dashboard"
data-variant="line">
<button class="tab-trigger" role="tab"
aria-selected="true"
aria-controls="line-panel-overview"
id="line-tab-overview">Overview</button>
<button class="tab-trigger" role="tab"
aria-selected="false"
aria-controls="line-panel-analytics"
id="line-tab-analytics"
tabindex="-1">Analytics</button>
<button class="tab-trigger" role="tab"
aria-selected="false"
aria-controls="line-panel-reports"
id="line-tab-reports"
tabindex="-1"
disabled>Reports</button>
</div>
<div class="tab-content" role="tabpanel"
id="line-panel-overview"
aria-labelledby="line-tab-overview"
tabindex="0">
<p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
Overview dashboard content goes here.
</p>
</div>
<div class="tab-content" role="tabpanel"
id="line-panel-analytics"
aria-labelledby="line-tab-analytics"
tabindex="0" hidden>
<p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
Analytics panel with charts and metrics.
</p>
</div>
<div class="tab-content" role="tabpanel"
id="line-panel-reports"
aria-labelledby="line-tab-reports"
tabindex="0" hidden>
<p class="text-muted-foreground" style="font-size:0.875rem;" style="padding:1rem 0;">
Reports panel with exportable data tables.
</p>
</div>
</div>
Vertical Orientation
Set aria-orientation="vertical" on the tablist. CSS uses :has() to switch to a side-by-side layout automatically.
General Settings
Manage your general account preferences.
Security
Configure your security settings.
Two-factor authentication, session management, and login history.
Notifications
Choose what you want to be notified about.
Email notifications, push alerts, and digest preferences.
<div class="tabs">
<div class="tab-list" role="tablist"
aria-label="Settings"
aria-orientation="vertical">
<button class="tab-trigger" role="tab"
aria-selected="true"
aria-controls="vert-panel-general"
id="vert-tab-general">General</button>
<button class="tab-trigger" role="tab"
aria-selected="false"
aria-controls="vert-panel-security"
id="vert-tab-security"
tabindex="-1">Security</button>
<button class="tab-trigger" role="tab"
aria-selected="false"
aria-controls="vert-panel-notif"
id="vert-tab-notif"
tabindex="-1">Notifications</button>
</div>
<div class="tab-content" role="tabpanel"
id="vert-panel-general"
aria-labelledby="vert-tab-general"
tabindex="0">
<div class="card">
<div class="card-header">
<h3 class="card-title" style="font-size:1rem;">General Settings</h3>
<p class="card-description">Manage your general account preferences.</p>
</div>
<div class="card-content" style="display:flex;flex-direction:column;gap:0.75rem;">
<div>
<label class="label" for="vert-display">Display name</label>
<input class="input" id="vert-display" type="text" value="Cody Lindley">
</div>
<div>
<label class="label" for="vert-email">Email</label>
<input class="input" id="vert-email" type="email" value="cody@example.com">
</div>
</div>
</div>
</div>
<div class="tab-content" role="tabpanel"
id="vert-panel-security"
aria-labelledby="vert-tab-security"
tabindex="0" hidden>
<div class="card">
<div class="card-header">
<h3 class="card-title" style="font-size:1rem;">Security</h3>
<p class="card-description">Configure your security settings.</p>
</div>
<div class="card-content">
<p class="text-muted-foreground" style="font-size:0.875rem;">Two-factor authentication, session management, and login history.</p>
</div>
</div>
</div>
<div class="tab-content" role="tabpanel"
id="vert-panel-notif"
aria-labelledby="vert-tab-notif"
tabindex="0" hidden>
<div class="card">
<div class="card-header">
<h3 class="card-title" style="font-size:1rem;">Notifications</h3>
<p class="card-description">Choose what you want to be notified about.</p>
</div>
<div class="card-content">
<p class="text-muted-foreground" style="font-size:0.875rem;">Email notifications, push alerts, and digest preferences.</p>
</div>
</div>
</div>
</div>
CSS view file
/* -- Tabs component -------------------------------------------- */
@layer components {
/* -- Default (pill) variant ----------------------------------- */
.tab-list {
display: inline-flex;
align-items: center;
background: var(--muted);
border-radius: var(--radius-lg);
padding: 0.25rem;
gap: 0.125rem;
/* -- Line variant --------------------------------------------- */
&[data-variant="line"] {
background: transparent;
border-radius: 0;
padding: 0;
gap: 0;
border-bottom: 1px solid var(--border);
& .tab-trigger {
border-radius: 0;
padding: 0.5rem 1rem;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
&[aria-selected="true"] {
background: transparent;
color: var(--foreground);
border-bottom-color: var(--primary);
box-shadow: none;
}
}
}
/* -- Vertical orientation ------------------------------------ */
&[aria-orientation="vertical"] {
flex-direction: column;
width: auto;
& .tab-trigger {
justify-content: flex-start;
width: 100%;
}
}
}
.tab-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: calc(var(--radius) * 0.6);
font-size: 0.875rem;
font-weight: 500;
border: none;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
outline: none;
&[aria-selected="true"] {
background: var(--background);
color: var(--foreground);
box-shadow: 0 1px 3px oklch(0 0 0 / 0.08), 0 0 0 1px var(--border);
}
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
&:disabled {
pointer-events: none;
opacity: 0.5;
}
}
/* -- Vertical layout container ----------------------------- */
.tabs:has(.tab-list[aria-orientation="vertical"]) {
display: flex;
gap: 1rem;
align-items: flex-start;
& > .tab-content {
margin-top: 0;
flex: 1;
}
}
/* -- Panel ---------------------------------------------------- */
.tab-content {
margin-top: 0.75rem;
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
&[hidden] { display: none; }
}
/* -- Accessibility ------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
.tab-trigger { transition: none; }
}
@media (forced-colors: active) {
.tab-trigger[aria-selected="true"] {
border: 2px solid Highlight;
box-shadow: none;
}
.tab-list[data-variant="line"] .tab-trigger[aria-selected="true"] {
border: none;
border-bottom: 2px solid Highlight;
}
}
}
JavaScript view file
Panel switching + WAI-ARIA keyboard navigation. Arrow keys cycle tabs, Home/End jump to first/last, disabled tabs are skipped automatically.
// -- Tabs -----------------------------------------------------
// ARIA-compliant keyboard navigation for [role="tablist"] elements.
const activateTab = (tab, triggers) => {
triggers.forEach((t) => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = true;
});
tab.setAttribute('aria-selected', 'true');
tab.removeAttribute('tabindex');
const panel = document.getElementById(tab.getAttribute('aria-controls'));
if (panel) panel.hidden = false;
};
function init() {
document.querySelectorAll('[role="tablist"]:not([data-init])').forEach((tablist) => {
tablist.dataset.init = '';
if (!tablist.querySelector('.tab-trigger')) return;
const triggers = Array.from(tablist.querySelectorAll('[role="tab"]'));
const orientation = tablist.getAttribute('aria-orientation') || 'horizontal';
triggers.forEach((trigger) => {
trigger.addEventListener('click', () => { activateTab(trigger, triggers); });
trigger.addEventListener('keydown', (e) => {
const current = triggers.indexOf(trigger);
let next;
const forward = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
const backward = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
switch (e.key) {
case forward:
e.preventDefault();
for (let i = 1; i <= triggers.length; i++) {
const c = triggers[(current + i) % triggers.length];
if (!c.disabled) { next = c; break; }
}
break;
case backward:
e.preventDefault();
for (let i = 1; i <= triggers.length; i++) {
const c = triggers[(current - i + triggers.length) % triggers.length];
if (!c.disabled) { next = c; break; }
}
break;
case 'Home':
e.preventDefault();
next = triggers.find((t) => !t.disabled);
break;
case 'End':
e.preventDefault();
next = triggers.slice().reverse().find((t) => !t.disabled);
break;
}
if (next && !next.disabled) {
activateTab(next, triggers);
next.focus();
}
});
});
});}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });