Tabs
Tabbed content
Intent
Tabs let the user switch between sibling sections of content in the same screen real estate -- a settings page split across profile / billing / team panels, an article that toggles between overview / install / changelog, a code example with multiple language tabs. The structure is two paired pieces: a row of tab triggers (the tablist) and a stack of panels, only one of which is visible at a time.
brio's tabs are an ARIA-first component (P3): semantics live in
role="tablist" / role="tab" /
role="tabpanel" attributes; CSS binds to those
attributes; the <brio-tabs> custom element
handles roving tabindex, keyboard navigation, panel switching,
and emits a brio:tab-change event so consumers can
fetch panel content lazily, instrument analytics, or update
URL state.
Reach for details when you have one collapsible block (not a stack of mutually-exclusive panels). Reach for dialog when the alternative views need to interrupt the flow. Tabs are for sibling views in the same content area.
Basic usage
Wrap a tablist + panels in <brio-tabs> with
class="tabs". Each tab needs
role="tab", an id, and an
aria-controls pointing at its panel's id. Each
panel needs role="tabpanel", a matching
id, and aria-labelledby pointing
back at its tab. The initially-selected tab gets
aria-selected="true"; all other tabs get
tabindex="-1" (the roving tabindex pattern). All
inactive panels get the hidden attribute.
Overview
brio is a small CSS component library for internal use, with a JS layer of progressive-enhancement custom elements where the platform doesn't go far enough.
Install
Clone the repo, run the build, link the bundled CSS and JS. Detailed steps in the README.
Changelog
v0.2 in progress: popover, dropdown, range, tabs, tooltip, plus an extras layer.
<docs-resize>
<brio-tabs class="tabs">
<div role="tablist">
<button class="tab" type="button" role="tab" aria-selected="true" aria-controls="ex-overview" id="ex-tab-overview">Overview</button>
<button class="tab" type="button" role="tab" aria-controls="ex-install" id="ex-tab-install" tabindex="-1">Install</button>
<button class="tab" type="button" role="tab" aria-controls="ex-changelog" id="ex-tab-changelog" tabindex="-1">Changelog</button>
</div>
<div class="tab-panel" role="tabpanel" id="ex-overview" aria-labelledby="ex-tab-overview">
<h3>Overview</h3>
<p>brio is a small CSS component library for internal use, with a JS layer of progressive-enhancement custom elements where the platform doesn't go far enough.</p>
</div>
<div class="tab-panel" role="tabpanel" id="ex-install" aria-labelledby="ex-tab-install" hidden="">
<h3>Install</h3>
<p>Clone the repo, run the build, link the bundled CSS and JS. Detailed steps in the README.</p>
</div>
<div class="tab-panel" role="tabpanel" id="ex-changelog" aria-labelledby="ex-tab-changelog" hidden="">
<h3>Changelog</h3>
<p>v0.2 in progress: popover, dropdown, range, tabs, tooltip, plus an extras layer.</p>
</div>
</brio-tabs>
</docs-resize>Variants
Pills
.as-pills drops the underline indicator and
paints the active tab with a pill-shaped background in
--color-primary-subtle. Useful when the tabs
sit on a colored surface where a thin underline would
disappear, or when the design calls for a more buttoned-up
feel.
Today's activity, hour by hour.
This week's activity, day by day.
This month's activity, week by week.
<docs-resize>
<brio-tabs class="tabs as-pills">
<div role="tablist">
<button class="tab" type="button" role="tab" aria-selected="true" aria-controls="pills-day" id="pills-tab-day">Day</button>
<button class="tab" type="button" role="tab" aria-controls="pills-week" id="pills-tab-week" tabindex="-1">Week</button>
<button class="tab" type="button" role="tab" aria-controls="pills-month" id="pills-tab-month" tabindex="-1">Month</button>
</div>
<div class="tab-panel" role="tabpanel" id="pills-day" aria-labelledby="pills-tab-day">
<p>Today's activity, hour by hour.</p>
</div>
<div class="tab-panel" role="tabpanel" id="pills-week" aria-labelledby="pills-tab-week" hidden="">
<p>This week's activity, day by day.</p>
</div>
<div class="tab-panel" role="tabpanel" id="pills-month" aria-labelledby="pills-tab-month" hidden="">
<p>This month's activity, week by week.</p>
</div>
</brio-tabs>
</docs-resize>Vertical
.as-vertical rotates the tablist into a column
on the inline-start side, with panels filling the space
beside it. The underline indicator becomes an inline-end
border. <brio-tabs> reads
.as-vertical from its classList and switches
arrow-key navigation to Up/Down (rather than Left/Right) and
sets aria-orientation="vertical" on the
tablist for screen readers.
Profile
Display name, avatar, contact details.
Billing
Current plan, payment method, invoice history.
Team
Members, roles, invitations.
API keys
Create, revoke, and rotate access tokens.
<docs-resize>
<brio-tabs class="tabs as-vertical">
<div role="tablist">
<button class="tab" type="button" role="tab" aria-selected="true" aria-controls="vert-profile" id="vert-tab-profile">Profile</button>
<button class="tab" type="button" role="tab" aria-controls="vert-billing" id="vert-tab-billing" tabindex="-1">Billing</button>
<button class="tab" type="button" role="tab" aria-controls="vert-team" id="vert-tab-team" tabindex="-1">Team</button>
<button class="tab" type="button" role="tab" aria-controls="vert-api" id="vert-tab-api" tabindex="-1">API keys</button>
</div>
<div class="tab-panel" role="tabpanel" id="vert-profile" aria-labelledby="vert-tab-profile">
<h3>Profile</h3>
<p>Display name, avatar, contact details.</p>
</div>
<div class="tab-panel" role="tabpanel" id="vert-billing" aria-labelledby="vert-tab-billing" hidden="">
<h3>Billing</h3>
<p>Current plan, payment method, invoice history.</p>
</div>
<div class="tab-panel" role="tabpanel" id="vert-team" aria-labelledby="vert-tab-team" hidden="">
<h3>Team</h3>
<p>Members, roles, invitations.</p>
</div>
<div class="tab-panel" role="tabpanel" id="vert-api" aria-labelledby="vert-tab-api" hidden="">
<h3>API keys</h3>
<p>Create, revoke, and rotate access tokens.</p>
</div>
</brio-tabs>
</docs-resize>Stretched
.as-stretched grows tabs equally to fill the
tablist width (flex: 1 on each). Composes with
the default underline look or with .as-pills.
Useful when the tab count is small and known and you want
the bar to span the container.
Pay month to month, cancel anytime.
One yearly payment, two months free.
<docs-resize>
<brio-tabs class="tabs as-stretched">
<div role="tablist">
<button class="tab" type="button" role="tab" aria-selected="true" aria-controls="str-monthly" id="str-tab-monthly">Monthly</button>
<button class="tab" type="button" role="tab" aria-controls="str-yearly" id="str-tab-yearly" tabindex="-1">Yearly (save 20%)</button>
</div>
<div class="tab-panel" role="tabpanel" id="str-monthly" aria-labelledby="str-tab-monthly">
<p>Pay month to month, cancel anytime.</p>
</div>
<div class="tab-panel" role="tabpanel" id="str-yearly" aria-labelledby="str-tab-yearly" hidden="">
<p>One yearly payment, two months free.</p>
</div>
</brio-tabs>
</docs-resize>State and interaction
Activation modes
Two modes per the WAI-ARIA APG, controlled by the
manual attribute on
<brio-tabs> (a JS-only behavioral toggle,
no CSS effect):
- Automatic (default): arrow keys move focus AND activate the next tab in one keypress. The user sees panel content change immediately as they navigate. Standard convention; matches what most users expect when panels switch instantly.
-
Manual (
<brio-tabs manual>): arrow keys move focus only; the user presses Enter or Space to activate the focused tab. Use when activation has side effects you don't want firing on every arrow press -- a network fetch for panel content, a lazy component instantiation, an instrumentation event sent on every panel view.
Manual activation: focus the tablist with Tab, walk between tabs with arrow keys, then press Enter or Space to switch panels.
Use this mode when panel switches are expensive (network, heavy compute) so keyboard users aren't firing N requests as they navigate.
Mouse click still activates immediately in either mode -- only keyboard arrow-key behavior differs.
<docs-resize>
<brio-tabs class="tabs" manual="">
<div role="tablist">
<button class="tab" type="button" role="tab" aria-selected="true" aria-controls="man-a" id="man-tab-a">Tab A</button>
<button class="tab" type="button" role="tab" aria-controls="man-b" id="man-tab-b" tabindex="-1">Tab B</button>
<button class="tab" type="button" role="tab" aria-controls="man-c" id="man-tab-c" tabindex="-1">Tab C</button>
</div>
<div class="tab-panel" role="tabpanel" id="man-a" aria-labelledby="man-tab-a">
<p>Manual activation: focus the tablist with Tab, walk between tabs with arrow keys, then press Enter or Space to switch panels.</p>
</div>
<div class="tab-panel" role="tabpanel" id="man-b" aria-labelledby="man-tab-b" hidden="">
<p>Use this mode when panel switches are expensive (network, heavy compute) so keyboard users aren't firing N requests as they navigate.</p>
</div>
<div class="tab-panel" role="tabpanel" id="man-c" aria-labelledby="man-tab-c" hidden="">
<p>Mouse click still activates immediately in either mode -- only keyboard arrow-key behavior differs.</p>
</div>
</brio-tabs>
</docs-resize>Keyboard
- Tab -- moves focus to the active tab
(the only tab in the page tab order; the others have
tabindex="-1"). Tab again leaves the tablist for the next focusable element on the page. - Arrow keys -- navigate between tabs.
Left / Right for horizontal tabs; Up / Down for
.as-vertical. Wraps at the ends. - Home / End -- jump to the first or last enabled tab.
- Enter / Space -- activate the focused tab. In automatic mode, redundant (already activated by arrow); in manual mode, the activation key.
- Disabled tabs
(
:disabledoraria-disabled="true") are skipped by keyboard navigation and not clickable, matching what the visual state communicates.
The brio:tab-change event
Every activation -- click, automatic-mode arrow, manual-mode
Enter/Space -- emits a brio:tab-change
CustomEvent on the <brio-tabs> element.
The event bubbles, so any ancestor can listen. Detail
payload:
{
tab, // the newly activated [role="tab"] element
previous, // the previously-active tab, or null on first activation
panel, // the activated [role="tabpanel"] element
panelId // string id of the activated panel
}
Listen via standard DOM:
document.querySelector("brio-tabs").addEventListener(
"brio:tab-change",
(e) => {
console.log("activated", e.detail.tab.textContent);
// e.g. fetch new panel content via SSE, update URL state,
// send analytics, etc.
}
);
The event fires only on actual changes -- clicking the already-selected tab is a no-op and does not re-emit. That keeps SSE / network-driven consumers from issuing duplicate fetches.
Class reference
.tabs-- the wrapper class, applied to<brio-tabs>. Owns layout direction, tokens, variants..tab-- styling hook for individual tab triggers. Pair withrole="tab"; either the class or the role drives styling..tab-panel-- styling hook for content panels. Pair withrole="tabpanel"; either works..as-pills-- pill-shaped triggers, no underline..as-vertical-- side-tab orientation; brio-tabs JS reads this to swap arrow-key direction and to setaria-orientation..as-stretched-- equal-width tabs filling the tablist's container.
Customization
Tabs scope tokens for chrome (--tab-padding-x,
--tab-padding-y, --tab-gap),
color (--tab-color,
--tab-color-active), and indicator
(--tab-border-color,
--tab-active-border-color). Variant rules
declare additional tokens (--tab-pill-active-bg,
--tab-seg-active-bg, etc.) only when those
variants apply.
Full token list and defaults:
src/css/tabs.css.
Accessibility
-
ARIA roles are required. The
component depends on
role="tablist",role="tab", androle="tabpanel". Without them, brio-tabs' keyboard behavior won't bind correctly and screen readers won't announce the structure as tabs. The.taband.tab-panelclasses are styling hooks, not semantic substitutes. -
Wire
aria-controlsandaria-labelledbyin pairs. Each tab'saria-controlsmust point at its panel'sid; each panel'saria-labelledbymust point back at its tab'sid. Screen readers use this pairing to announce "X of Y tabs, panel for X" as the user navigates. -
Roving tabindex is JS-managed.
Initial markup should set
tabindex="-1"on every tab except the active one (which has no tabindex, ortabindex="0"); brio-tabs keeps the pattern correct as activation changes. Don't authortabindex="0"on inactive tabs -- Tab would visit them all, breaking the convention. -
Vertical sets aria-orientation
automatically. brio-tabs adds
aria-orientation="vertical"to the tablist when the host has.as-vertical. Don't author it manually; let the JS manage it so it stays in sync if the variant class is toggled at runtime. -
Hidden panels stay in the DOM. brio-
tabs uses the
hiddenattribute, which excludes panels from the accessibility tree and from Tab navigation but keeps them in the DOM. Heavy panels can use lazy-loading patterns via thebrio:tab-changeevent; the panel element stays present, but its content can be populated on demand.
CSS Reference: Tabs
ARIA-tabbed content with tablist and panels
Source: src/css/tabs.css
Tokens
| Token | Default | Description |
|---|---|---|
--tab-font-size | var(--ui-text-sm) | Font size for tab triggers |
--tab-padding-x | var(--ui-pad-x-compact) | Horizontal padding for tab triggers |
--tab-padding-y | var(--ui-pad-y-tight) | Vertical padding for tab triggers |
--tab-gap | var(--gap-tight) | Gap between tab triggers |
--tab-color | var(--color-text-muted) | Text color for inactive tabs |
--tab-color-active | var(--color-primary) | Text color for the active tab |
--tab-border-color | var(--color-border) | Border color for the tablist underline |
--tab-active-border-color | var(--color-primary) | Border color for the active tab indicator |
--tab-panel-padding-block | var(--space-md) | Vertical padding for tab panels |
Slots
| Slot | Description |
|---|---|
.tabs [role="tablist"] | Tablist -- styled via the role since it's a structural container, * not a separately-named slot. Inset shadow is a deliberate choice: * the active tab's bottom-border indicator overlays cleanly without * a negative margin, which would get clipped by overflow-x scrolling. |
.tabs :is([role="tab"], .tab) | Tab -- both .tab class and [role="tab"] are styling hooks. Same * dual-pairing pattern as .dropdown-item + [role="menuitem"]. |
Classes
| Class | Description |
|---|---|
.tabs | component root |
.as-vertical | Side-tab orientation with column tablist |
.as-stretched | Tabs grow equally to fill the tablist width |
.as-pills | Pill-shaped triggers with filled active state |