Tabs
Description
Section titled “Description”.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.
Details content goes here.
Specs 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>Responsive (container query)
Section titled “Responsive (container query)”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.
Privacy settings.
Advanced 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>Definition list variant
Section titled “Definition list variant”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
--bp-space-4- Base spacing unit (1rem)
<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>JavaScript
Section titled “JavaScript”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]) } }) })})Public API
Section titled “Public API”| Variable | Default | Description |
|---|---|---|
--tabs-accent | var(--bp-primary) | Active tab underline / left-border color |
--tabs-border | 1px solid var(--bp-color-border) | Tab list divider |
--tabs-gap | var(--bp-space-2) | Space between tab buttons |
Customization example
Section titled “Customization example”Brand variant
Active items.
Archived 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>Accessibility
Section titled “Accessibility”role="tablist"requiresaria-labelwhen no visible heading labels the group.- Each
role="tab"must havearia-controlspointing to its panel’sid, andidreferenced byaria-labelledbyon 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.
Browser APIs
Section titled “Browser APIs”| API | Availability | Used for |
|---|---|---|
@container | Widely available Baseline 2023 | Responsive tab layout |
container-type: inline-size | Widely available Baseline 2023 | Establishes container context |
Internals
Section titled “Internals”--_accent,--_border,--_gap— component-private, do not set directly.