Skip to content

Tabs

.bp-tabs is a fully accessible tabs component. CSS drives all visual state via [aria-selected="true"] selectors. A small inline script handles ARIA state switching and keyboard navigation (roving tabindex). @container switches from a horizontal row to a vertical stack at narrow widths — no media queries needed.

Default

Overview content goes here.

<div class="bp-tabs">
<div class="bp-tabs__list" role="tablist" aria-label="Product details">
<button class="bp-tabs__tab" role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1" tabindex="0">Overview</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">Details</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">Specs</button>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
<p>Overview content goes here.</p>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
<p>Details content goes here.</p>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="panel-3" aria-labelledby="tab-3" tabindex="0" hidden>
<p>Specs content goes here.</p>
</div>
</div>
<script>
document.querySelectorAll('.bp-tabs').forEach((root) => {
const tabs = Array.from(root.querySelectorAll('[role="tab"]'))
function activate(tab) {
tabs.forEach((t) => { t.setAttribute('aria-selected', 'false'); t.setAttribute('tabindex', '-1') })
tab.setAttribute('aria-selected', 'true'); tab.setAttribute('tabindex', '0'); tab.focus()
root.querySelectorAll('[role="tabpanel"]').forEach((p) => { p.hidden = true })
const target = root.querySelector('#' + tab.getAttribute('aria-controls'))
if (target) target.hidden = false
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => activate(tab))
tab.addEventListener('keydown', (e) => {
const i = tabs.indexOf(e.currentTarget)
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); activate(tabs[(i + 1) % tabs.length]) }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); activate(tabs[(i - 1 + tabs.length) % tabs.length]) }
else if (e.key === 'Home') { e.preventDefault(); activate(tabs[0]) }
else if (e.key === 'End') { e.preventDefault(); activate(tabs[tabs.length - 1]) }
})
})
})
</script>

At container widths below 480px, the tab list stacks vertically and the active underline moves to the left edge. Wrap .bp-tabs in a constrained container to see it:

Narrow container

General settings.

<style>
.demo-tabs--narrow { max-width: 300px; }
</style>
<div class="demo-tabs--narrow">
<div class="bp-tabs">
<div class="bp-tabs__list" role="tablist" aria-label="Settings">
<button class="bp-tabs__tab" role="tab" aria-selected="true" aria-controls="s-panel-1" id="s-tab-1" tabindex="0">General</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="s-panel-2" id="s-tab-2" tabindex="-1">Privacy</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="s-panel-3" id="s-tab-3" tabindex="-1">Advanced</button>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="s-panel-1" aria-labelledby="s-tab-1" tabindex="0"><p>General settings.</p></div>
<div class="bp-tabs__panel" role="tabpanel" id="s-panel-2" aria-labelledby="s-tab-2" tabindex="0" hidden><p>Privacy settings.</p></div>
<div class="bp-tabs__panel" role="tabpanel" id="s-panel-3" aria-labelledby="s-tab-3" tabindex="0" hidden><p>Advanced settings.</p></div>
</div>
</div>

When tab content represents term/definition pairs (glossaries, API docs, property sheets), use <dl> as the root element. The same CSS classes apply — no extra styles needed.

dl variant

--bp-primary
Brand primary color
<dl class="bp-tabs">
<div class="bp-tabs__list" role="tablist" aria-label="Token types">
<button class="bp-tabs__tab" role="tab" aria-selected="true" aria-controls="dl-panel-1" id="dl-tab-1" tabindex="0">Color</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="dl-panel-2" id="dl-tab-2" tabindex="-1">Spacing</button>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="dl-panel-1" aria-labelledby="dl-tab-1" tabindex="0">
<dt><code>--bp-primary</code></dt>
<dd>Brand primary color</dd>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="dl-panel-2" aria-labelledby="dl-tab-2" tabindex="0" hidden>
<dt><code>--bp-space-4</code></dt>
<dd>Base spacing unit (1rem)</dd>
</div>
</dl>

Copy this script once per page that uses .bp-tabs. It initialises all tab instances automatically.

document.querySelectorAll('.bp-tabs').forEach((root) => {
const tabs = Array.from(root.querySelectorAll('[role="tab"]'))
function activate(tab) {
tabs.forEach((t) => {
t.setAttribute('aria-selected', 'false')
t.setAttribute('tabindex', '-1')
})
tab.setAttribute('aria-selected', 'true')
tab.setAttribute('tabindex', '0')
tab.focus()
root.querySelectorAll('[role="tabpanel"]').forEach((panel) => {
panel.hidden = true
})
const target = root.querySelector('#' + tab.getAttribute('aria-controls'))
if (target) target.hidden = false
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => activate(tab))
tab.addEventListener('keydown', (e) => {
const i = tabs.indexOf(e.currentTarget)
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
activate(tabs[(i + 1) % tabs.length])
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
activate(tabs[(i - 1 + tabs.length) % tabs.length])
} else if (e.key === 'Home') {
e.preventDefault()
activate(tabs[0])
} else if (e.key === 'End') {
e.preventDefault()
activate(tabs[tabs.length - 1])
}
})
})
})
VariableDefaultDescription
--tabs-accentvar(--bp-primary)Active tab underline / left-border color
--tabs-border1px solid var(--bp-color-border)Tab list divider
--tabs-gapvar(--bp-space-2)Space between tab buttons

Brand variant

Active items.

<style>
.demo-tabs--success { --tabs-accent: var(--bp-color-success); }
</style>
<div class="bp-tabs demo-tabs--success">
<div class="bp-tabs__list" role="tablist" aria-label="Status">
<button class="bp-tabs__tab" role="tab" aria-selected="true" aria-controls="c-panel-1" id="c-tab-1" tabindex="0">Active</button>
<button class="bp-tabs__tab" role="tab" aria-selected="false" aria-controls="c-panel-2" id="c-tab-2" tabindex="-1">Archived</button>
</div>
<div class="bp-tabs__panel" role="tabpanel" id="c-panel-1" aria-labelledby="c-tab-1" tabindex="0"><p>Active items.</p></div>
<div class="bp-tabs__panel" role="tabpanel" id="c-panel-2" aria-labelledby="c-tab-2" tabindex="0" hidden><p>Archived items.</p></div>
</div>
No axe violations tested 2026-05-11
  • role="tablist" requires aria-label when no visible heading labels the group.
  • Each role="tab" must have aria-controls pointing to its panel’s id, and id referenced by aria-labelledby on the panel.
  • Active tab: aria-selected="true", tabindex="0". Inactive: aria-selected="false", tabindex="-1".
  • All panels have tabindex="0" so keyboard users can focus into panel content with Tab.
  • Keyboard navigation follows the ARIA Tabs Pattern: arrow keys move between tabs, Tab moves into the active panel.
APIAvailabilityUsed for
@container Widely available Baseline 2023 Responsive tab layout
container-type: inline-size Widely available Baseline 2023 Establishes container context
  • --_accent, --_border, --_gap — component-private, do not set directly.