Tabs
An accessible tabs widget with automatic activation. The tablist holds a roving tabindex so
Tab / Shift+Tab enter and leave the group as a single keyboard stop; arrow keys move between
tabs and immediately select them. State is expressed through real ARIA attributes — aria-selected
drives both the active indicator and the label color; disabled is the native button attribute.
The examples are organized by layer: the class reference is the standalone
@oriui/css layer, and the Framework API is the @oriui/vue component. Every
example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte /
plain-HTML usage), Vue; HTML is the default.
Classes
A tabs widget is a block class plus single-class token utilities — one class repoints one token, no base class needed. The Vue props in Framework API map 1:1 to these.
| Class | Type | Description |
|---|---|---|
ori-tabs | Block | Required base class. Root flex container; column layout (horizontal) or row layout (vertical). Baked default accent is primary; override with ori-color_* to change the indicator and ring. |
ori-tabs_vertical | Modifier | Vertical orientation: row flex layout, column tablist, right side-bar indicator instead of bottom underline. |
ori-color_* | Color | primary · secondary · success · warning · danger · info · surface — indicator and focus ring accent. |
ori-tabs__list | Part | The role=tablist container; bottom border in horizontal layout, right border in vertical layout. |
ori-tabs__tab | Part | A real button with role=tab. Active state via aria-selected=true (indicator scales in, label color shifts). Disabled via native disabled attribute. |
ori-tabs__panel | Part | A role=tabpanel with tabindex=0 and aria-labelledby its tab. Only the active panel is shown. |
aria-selected · disabled | State | Real attributes, not classes. aria-selected=true scales the after indicator; disabled dims and blocks. |
À la carte: the classes above ship in @oriui/css/components/tabs.css. Import a foundation
(@oriui/css/base.css or @oriui/css/tokens.css) first — the token utilities (ori-color_*,
ori-size-radius_*, …) live there, not in the component file. The full bundle @oriui/css is the default and
already carries both; see à-la-carte imports.
The selected tab's label reads the AA-safe --ori-color-text tone (not the raw role) — see
Design tokens.
Anatomy
div.ori-tabs [ori-color_primary]
div.ori-tabs__list [role=tablist, aria-orientation]
button.ori-tabs__tab [role=tab, aria-selected, aria-controls, tabindex] × N
div.ori-tabs__panel [role=tabpanel, aria-labelledby, tabindex=0] × N
Basic
<div class="ori-tabs ori-color_primary">
<div class="ori-tabs__list" role="tablist" aria-orientation="horizontal">
<button
id="tabs-1-tab-0"
class="ori-tabs__tab"
type="button"
role="tab"
aria-selected="true"
aria-controls="tabs-1-panel-0"
tabindex="0"
>
Overview
</button>
<button
id="tabs-1-tab-1"
class="ori-tabs__tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="tabs-1-panel-1"
tabindex="-1"
>
Specs
</button>
<button
id="tabs-1-tab-2"
class="ori-tabs__tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="tabs-1-panel-2"
tabindex="-1"
>
Reviews
</button>
</div>
<div id="tabs-1-panel-0" class="ori-tabs__panel" role="tabpanel" aria-labelledby="tabs-1-tab-0" tabindex="0">
Overview content goes here.
</div>
<div id="tabs-1-panel-1" class="ori-tabs__panel" role="tabpanel" aria-labelledby="tabs-1-tab-1" tabindex="0" hidden>
Technical specifications.
</div>
<!-- … -->
</div>
Colors
Every semantic role. The accent color is shared by the active indicator and the focus ring.
<!-- swap the color: ori-color_primary → _secondary / _success / _danger / _warning / _info -->
<div class="ori-tabs ori-color_secondary">…</div>
Vertical orientation
orientation="vertical" stacks the tablist in a column to the left with a right side-bar indicator.
Arrow axis switches to Up / Down.
<!-- add ori-tabs_vertical; list gets aria-orientation="vertical" -->
<div class="ori-tabs ori-tabs_vertical ori-color_primary">
<div class="ori-tabs__list" role="tablist" aria-orientation="vertical">
<button class="ori-tabs__tab" role="tab" aria-selected="true" tabindex="0">General</button>
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1">Security</button>
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1">Notifications</button>
</div>
<div class="ori-tabs__panel" role="tabpanel" tabindex="0">General settings.</div>
<!-- … -->
</div>
Disabled tabs
Set disabled: true on individual tab items to lock those tabs. Disabled tabs are real <button disabled> — they are removed from tab order, unreachable by keyboard nav, and visually dimmed.
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1" disabled>Locked</button>
Panel slots — per-tab content
Use a #panel-<value> slot to give each panel its own markup. A scoped #default="{ tab }" slot is the fallback for one template shared across panels.
The fallback renders into the active panel only — one instance, always the visible one, and the { tab }
it receives is always the selected tab. One panel, one content is the rule for every slot in this component.
It used to render into every panel, which multiplied a template that ignored its scope: with two tabs and a
login form in #default the form existed twice, and document.getElementById('email') resolved to whichever
copy came first in document order — the hidden panel whenever the active tab was not the first one, so a
<label for> in the visible panel pointed at an input nobody could reach.
What the rule costs: uncontrolled DOM state inside the shared template (an unsent draft, a scroll position)
does not survive a tab switch, because the content is created fresh in the panel you switch to. Content that
must persist per tab belongs in #panel-<value>, which renders into its own panel whether or not that tab is
selected. Content identical for every tab belongs outside <OriTabs> entirely.
<!-- Panels are arbitrary markup inside each ori-tabs__panel -->
<div class="ori-tabs__panel" role="tabpanel" tabindex="0">
<p>Rendered preview of the component.</p>
</div>
Uncontrolled (no v-model)
When v-model is omitted, the component auto-selects the first non-disabled tab immediately and stays self-contained. If the active value ever points at a missing or disabled tab, it self-heals to the first non-disabled tab.
<!-- Same markup; the first tab gets aria-selected="true" by default -->
<div class="ori-tabs ori-color_primary">…</div>
Common patterns
Vertical orientation, a non-default color, and named panel slots — a common settings sidebar pattern.
<div class="ori-tabs ori-tabs_vertical ori-color_secondary">
<div class="ori-tabs__list" role="tablist" aria-orientation="vertical">
<button class="ori-tabs__tab" role="tab" aria-selected="true" tabindex="0">Profile</button>
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1">Account</button>
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1">Privacy</button>
</div>
<div class="ori-tabs__panel" role="tabpanel" tabindex="0">
<h2>Profile settings</h2>
<p>Update your name, avatar, and bio.</p>
</div>
<!-- … -->
</div>
Accessibility
The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes and keyboard behavior.
- Full WAI-ARIA Tabs pattern, driven by the headless
useTabscomposable (its roving index/key math lives in the framework-agnostic core).role="tablist"on the list witharia-orientationreflecting theorientationprop. - Each tab is a real
<button role="tab">with auseId()-derived stable id,aria-selected("true"/"false"),aria-controlspointing at its panel id, and a roving tabindex (selected =0, others =-1) soTab/Shift+Tabenter and leave the group as a single stop. - Each panel is
role="tabpanel"withtabindex="0"andaria-labelledbyits owning tab — the panel is focusable so keyboard users can reach its content. - Activation is automatic — focus move also selects. Arrow keys move to the previous/next non-disabled tab with wrap-around.
- Disabled tabs are real
<button disabled>— removed from tab order, not focusable, not selectable, and skipped by arrow-key navigation. - Focus is always visible:
:focus-visibleon both the tab button and the panel, usingvar(--ori-color, currentcolor)so the ring color matches the active indicator. - Tab/panel id pairs are SSR-safe via
useId().
| Key | Action |
|---|---|
Tab | Moves focus into the tablist (to the selected tab) or past it. |
Shift+Tab | Moves focus out of the tablist (or from the panel back before the list). |
ArrowRight / ArrowLeft | Horizontal: selects and focuses the next / previous non-disabled tab. Swapped in RTL. |
ArrowDown / ArrowUp | Vertical: selects and focuses the next / previous non-disabled tab. |
Home | Selects and focuses the first non-disabled tab. |
End | Selects and focuses the last non-disabled tab. |
Framework API
The props, events, and slots of the Vue component. The standalone CSS layer has no component API — its surface is the classes above.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | 'primary' | Active-tab accent: drives the indicator (underline / side-bar), the active tab label color, and the focus ring. |
label | string | — | Accessible name for the tablist (→ aria-label). WAI-ARIA recommends naming a tablist, especially with several on a page. |
modelValue | string | number | first non-disabled tab value | Active tab value (v-model). Auto-defaults to the first non-disabled tab; self-heals if the value becomes invalid. |
orientation | Orientation | 'horizontal' | Layout + keyboard axis. horizontal = row tablist with underline; vertical = column tablist with right side-bar. |
tabs | TabItem[] | — (required) | The set of tabs. Each item: value (unique key), label (visible text), optional disabled. |
Orientation: 'horizontal' | 'vertical'
TabItem is exported — import type { TabItem } from '@oriui/vue'. It is the headless TabItem
(value, optional disabled) plus the label this styled shell renders, so a TabItem[] can be
handed straight to useTabs:
import type { TabItem } from '@oriui/vue'
const tabs: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'specs', label: 'Specs', disabled: true }
]
Events & attributes
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | number | Emitted when the user activates a tab (via click or keyboard). Use v-model to bind. |
OriTabs does not set inheritAttrs: false, so extra attributes (class, data-*, …) fall through
to the root div.ori-tabs.
Slots
| Slot | Scope | Description |
|---|---|---|
tab | { tab: { value: string | number; label: string; disabled?: boolean } } | Tab trigger. Custom content for the tab button — icon, count badge, and the like. Applies to every tab; read the per-tab item from scope. Falls back to tab.label. |
panel-<value> | { tab: { value: string | number; label: string; disabled?: boolean } } | Primary. A per-value named slot — name the slot panel- plus the tab's value (e.g. #panel-account for value 'account'). Numeric values are stringified for the slot name. The prefix keeps caller data out of the reserved #tab / #default namespace. |
default | { tab: { value: string | number; label: string; disabled?: boolean } } | Fallback. A scoped default slot rendered into the active panel, when that panel has no matching named slot. One instance, always visible, and the tab in scope is always the selected one. Uncontrolled DOM state inside it does not survive a tab switch — use #panel-<value> for that, or put genuinely shared content outside the component. |