02 / Organize
Tabs
A horizontal tablist with automatic activation, roving focus, and linked panels.
Try it.
Isolated preview using the source below. In the playground, click Run to start.
Copy. Register. Use.
Keep the template outside your mount region and register the definition on your existing
app before mounting. Pass props for data and slots for custom markup.
Adapt the example parent state to your app, or create one with
brio.createApp({ state: exampleState }). Register once; use multiple instances.
<template id="brio-tabs-template"> <section class="part-root" b-id="['title', 'tabs']" b-bind-aria-labelledby="$id.title" b-bind-style="{ '--part-accent': $props.accent || null }"> <style> @scope { :scope { display: block; font: 15px/1.65 system-ui, sans-serif; color: light-dark(#152139, #edf3ff); } * { box-sizing: border-box; } h2 { margin: 0 0 18px; font-size: 1.5rem; line-height: 1.3; letter-spacing: -.035em; } h3 { margin-top: 0; } p { margin-bottom: 0; } [hidden] { display: none !important; } [role="tablist"] { display: flex; gap: 6px; border-bottom: 1px solid light-dark(#d5e0ef, #344863); padding-bottom: 10px; } button { cursor: pointer; font: inherit; color: inherit; background: transparent; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 7px; padding: 8px 14px; } button.is-selected { background: light-dark(#eef5ff, #1b3251); border-color: var(--part-accent, light-dark(#245fe2, #9bc1ff)); } :focus-visible { outline: 3px solid var(--part-accent, light-dark(#245fe2, #9bc1ff)); outline-offset: 3px; } [role="tabpanel"] { padding: 24px 4px; } } </style> <h2 b-bind-id="$id.title" b-text="$props.title || $data.content.title"></h2> <div role="tablist" b-bind-aria-label="$props.label || $data.content.label" b-on-keydown="$data.onKey($event)"> <button type="button" role="tab" b-for="item in $data.content.items" b-key="$for.item.id" b-bind-id="$data.tabId($id.tabs, $for.item.id)" b-bind-aria-controls="$data.panelId($id.tabs, $for.item.id)" b-bind-class="{ 'is-selected': $data.isSelected($for.item.id) }" b-bind-aria-selected="$data.isSelected($for.item.id)" b-bind-tabindex="$data.isSelected($for.item.id) ? 0 : -1" b-on-click="$data.select($for.item.id)" b-text="$for.item.label"></button> </div> <section role="tabpanel" b-for="item in $data.content.items" b-key="$for.item.id" b-bind-id="$data.panelId($id.tabs, $for.item.id)" b-bind-aria-labelledby="$data.tabId($id.tabs, $for.item.id)" tabindex="0" b-bind-hidden="!$data.isSelected($for.item.id)"> <slot name="panel" b-slot="{ item: $for.item }"> <h3 b-text="$for.item.heading"></h3><p b-text="$for.item.body"></p> </slot> </section> </section></template>const tabsComponent = { template: '#brio-tabs-template', props: { title: String, label: String, accent: String }, setup({ el, $props }) { return { get content() { const content = $props.content; if (!content || !Array.isArray(content.items)) throw new Error('brio-tabs requires a content object with items.'); return content; }, selected: null, get activeId() { return this.content.items.some(item => item.id === this.selected) ? this.selected : this.content.items[0]?.id; }, isSelected(id) { return this.activeId === id; }, select(id) { this.selected = id; }, tabId(prefix, id) { return `${prefix}-tab-${encodeURIComponent(id)}`; }, panelId(prefix, id) { return `${prefix}-panel-${encodeURIComponent(id)}`; }, onKey(event) { const tabs = [...el.querySelectorAll('[role="tab"]')]; const current = tabs.indexOf(event.target); if (current < 0 || event.altKey || event.ctrlKey || event.metaKey) return; let next; if (event.key === 'ArrowRight') next = (current + 1) % tabs.length; if (event.key === 'ArrowLeft') next = (current + tabs.length - 1) % tabs.length; if (event.key === 'Home') next = 0; if (event.key === 'End') next = tabs.length - 1; if (next === undefined) return; event.preventDefault(); this.select(this.content.items[next].id); tabs[next].focus(); }, }; },};app.component('brio-tabs', tabsComponent);const exampleState = () => ({ content: { "title": "Your next little adventure.", "label": "Trip planning", "items": [ { "id": "pack", "label": "Pack", "heading": "Bring the essentials", "body": "A notebook, a water bottle, and room for something unexpected." }, { "id": "travel", "label": "Travel", "heading": "Take the scenic route", "body": "Leave early. Look out the window. Stop when something catches your eye." }, { "id": "enjoy", "label": "Enjoy", "heading": "Be here for a while", "body": "Put the schedule away and find a good place to sit." } ] }});<brio-tabs b-prop-content="$data.content"></brio-tabs><brio-tabs b-prop-content="$data.content"> <div slot="panel"> <strong b-text="$slot.item.heading"></strong> <p b-if="$slot.item.id === 'pack'" style="color: red;" b-text="$slot.item.body"></p> <p b-else b-text="$slot.item.body"></p> <!-- Add trusted custom markup or other components here. --> </div></brio-tabs>Make it yours.
Supply content.items as { id, label, heading, body } objects and content.label for the tablist name. Selection follows item IDs through reordering; the first item is shown when the selected ID is absent.
- Panels render locally, so automatic activation does not wait for a network request.
- Inactive panels stay mounted and hidden. Their bindings and effects remain active; switching tabs does not reset panel state.
- Horizontal, left-to-right only. Adapt keyboard handling and aria-orientation for vertical tabs or RTL layouts.
Content, props & slots
Replace the example data, not the template. Data-driven text renders as plain text.
Use b-prop-content to pass the object; a static content="..." attribute is not parsed as JSON.
Optional title overrides content.title.
Optional label overrides the tablist name.
Items need unique, nonempty string id values that stay stable across updates.
Empty lists are supported.
panel: repeated custom panel markup with $slot.item. Use b-if for item-specific layouts. Without supplied content, panels use their heading and body text. Tab buttons and ARIA relationships stay component-owned.
Slotted markup uses the parent's state, refs, and handlers.
Scoped outlets expose item data through
$slot.item, without changing $data or $for.
Keep slot templates trusted. Default content is used when no matching slot is supplied.
b-prop-* supplies component inputs; b-bind-* updates the host DOM.
To spread inputs, use b-props: its keys override named props regardless of attribute order.
Styling & requirements
Requires Brio 5 core, JavaScript, and native CSS @scope and light-dark() support.
CSS is included in the template; no add-ons or global stylesheet is required. No CSS polyfill is supplied.
Colors follow the document's color-scheme; set it to light dark to follow system preference.
Set the accent prop with b-prop-accent="$data.accent", or bind
--part-accent on the host or a wrapper. The accent prop takes precedence over an inherited
custom property. Native scoped CSS does not block global selectors or inherited styles;
check your app's resets and theme. Each instance has local state and unique generated IDs.
Integration & accessibility
Components render on mount; provide fallback markup if content must be available without JavaScript.
Keep templates trusted. Use Brio structural directives for component removal and
app.dispose() before removing the app. With the standard bundle, expressions require CSP
unsafe-eval. The CSP build
rejects #id templates, so pass the template markup as a string in the definition before using a Part there.
See the integration guide for lifecycle and server boundaries.
Test your final labels, contrast, zoom, touch, and screen-reader behavior. These recipes are not an accessibility certification.