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.

<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&#39;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.

<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&#39;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&#39;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&#39;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.

<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.

<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.

<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&#39;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 (:disabled or aria-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 with role="tab"; either the class or the role drives styling.
  • .tab-panel -- styling hook for content panels. Pair with role="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 set aria-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", and role="tabpanel". Without them, brio-tabs' keyboard behavior won't bind correctly and screen readers won't announce the structure as tabs. The .tab and .tab-panel classes are styling hooks, not semantic substitutes.
  • Wire aria-controls and aria-labelledby in pairs. Each tab's aria-controls must point at its panel's id; each panel's aria-labelledby must point back at its tab's id. 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, or tabindex="0"); brio-tabs keeps the pattern correct as activation changes. Don't author tabindex="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 hidden attribute, 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 the brio:tab-change event; 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

TokenDefaultDescription
--tab-font-sizevar(--ui-text-sm)Font size for tab triggers
--tab-padding-xvar(--ui-pad-x-compact)Horizontal padding for tab triggers
--tab-padding-yvar(--ui-pad-y-tight)Vertical padding for tab triggers
--tab-gapvar(--gap-tight)Gap between tab triggers
--tab-colorvar(--color-text-muted)Text color for inactive tabs
--tab-color-activevar(--color-primary)Text color for the active tab
--tab-border-colorvar(--color-border)Border color for the tablist underline
--tab-active-border-colorvar(--color-primary)Border color for the active tab indicator
--tab-panel-padding-blockvar(--space-md)Vertical padding for tab panels

Slots

SlotDescription
.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

ClassDescription
.tabscomponent root
.as-verticalSide-tab orientation with column tablist
.as-stretchedTabs grow equally to fill the tablist width
.as-pillsPill-shaped triggers with filled active state