oriUI

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.

ClassTypeDescription
ori-tabsBlockRequired 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_verticalModifierVertical orientation: row flex layout, column tablist, right side-bar indicator instead of bottom underline.
ori-color_*Colorprimary · secondary · success · warning · danger · info · surface — indicator and focus ring accent.
ori-tabs__listPartThe role=tablist container; bottom border in horizontal layout, right border in vertical layout.
ori-tabs__tabPartA real button with role=tab. Active state via aria-selected=true (indicator scales in, label color shifts). Disabled via native disabled attribute.
ori-tabs__panelPartA role=tabpanel with tabindex=0 and aria-labelledby its tab. Only the active panel is shown.
aria-selected · disabledStateReal 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

text
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

html
<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>
vue
<OriTabs
    v-model="active"
    :tabs="[
        { value: 'overview', label: 'Overview' },
        { value: 'specs', label: 'Specs' },
        { value: 'reviews', label: 'Reviews' }
    ]"
>
    <template #panel-overview>Overview content goes here.</template>
    <template #panel-specs>Technical specifications.</template>
    <template #panel-reviews>Customer reviews.</template>
</OriTabs>

Colors

Every semantic role. The accent color is shared by the active indicator and the focus ring.

html
<!-- swap the color: ori-color_primary → _secondary / _success / _danger / _warning / _info -->
<div class="ori-tabs ori-color_secondary">…</div>
vue
<OriTabs color="primary" :tabs="tabs" />
<OriTabs color="secondary" :tabs="tabs" />
<OriTabs color="success" :tabs="tabs" />
<OriTabs color="danger" :tabs="tabs" />

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.

html
<!-- 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>
vue
<OriTabs
    v-model="section"
    orientation="vertical"
    :tabs="[
        { value: 'general', label: 'General' },
        { value: 'security', label: 'Security' },
        { value: 'notifications', label: 'Notifications' }
    ]"
>
    <template #panel-general>General settings.</template>
    <template #panel-security>Security settings.</template>
    <template #panel-notifications>Notification preferences.</template>
</OriTabs>

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.

html
<button class="ori-tabs__tab" role="tab" aria-selected="false" tabindex="-1" disabled>Locked</button>
vue
<OriTabs
    v-model="active"
    :tabs="[
        { value: 'active', label: 'Active' },
        { value: 'locked', label: 'Locked', disabled: true },
        { value: 'another', label: 'Another' }
    ]"
/>

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.

html
<!-- 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>
vue
<!-- Primary: a named slot per value — distinct markup per panel -->
<OriTabs v-model="tab" :tabs="tabs">
    <template #panel-preview>
        <p>Rendered preview of the component.</p>
    </template>
    <template #panel-code>
        <pre>const x = 1;</pre>
    </template>
    <template #panel-docs>
        <p>Documentation and API reference.</p>
    </template>
</OriTabs>

<!-- Fallback: one shared template that reads the active tab from scope -->
<OriTabs v-model="tab" :tabs="tabs">
    <template #default="{ tab }">
        <p>Content for: {{ tab.label }}</p>
    </template>
</OriTabs>

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.

html
<!-- Same markup; the first tab gets aria-selected="true" by default -->
<div class="ori-tabs ori-color_primary">…</div>
vue
<!-- No v-model — the component manages its own active state -->
<OriTabs
    :tabs="[
        { value: 'one', label: 'First' },
        { value: 'two', label: 'Second' },
        { value: 'three', label: 'Third' }
    ]"
/>

Common patterns

Vertical orientation, a non-default color, and named panel slots — a common settings sidebar pattern.

html
<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>
vue
<OriTabs
    v-model="settingsSection"
    orientation="vertical"
    color="secondary"
    :tabs="[
        { value: 'profile', label: 'Profile' },
        { value: 'account', label: 'Account' },
        { value: 'privacy', label: 'Privacy' }
    ]"
>
    <template #panel-profile>
        <h2>Profile settings</h2>
        <p>Update your name, avatar, and bio.</p>
    </template>
    <template #panel-account>
        <h2>Account settings</h2>
        <p>Manage your email address and password.</p>
    </template>
    <template #panel-privacy>
        <h2>Privacy settings</h2>
        <p>Control who can see your activity.</p>
    </template>
</OriTabs>

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 useTabs composable (its roving index/key math lives in the framework-agnostic core). role="tablist" on the list with aria-orientation reflecting the orientation prop.
  • Each tab is a real <button role="tab"> with a useId()-derived stable id, aria-selected ("true" / "false"), aria-controls pointing at its panel id, and a roving tabindex (selected = 0, others = -1) so Tab / Shift+Tab enter and leave the group as a single stop.
  • Each panel is role="tabpanel" with tabindex="0" and aria-labelledby its 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-visible on both the tab button and the panel, using var(--ori-color, currentcolor) so the ring color matches the active indicator.
  • Tab/panel id pairs are SSR-safe via useId().
KeyAction
TabMoves focus into the tablist (to the selected tab) or past it.
Shift+TabMoves focus out of the tablist (or from the panel back before the list).
ArrowRight / ArrowLeftHorizontal: selects and focuses the next / previous non-disabled tab. Swapped in RTL.
ArrowDown / ArrowUpVertical: selects and focuses the next / previous non-disabled tab.
HomeSelects and focuses the first non-disabled tab.
EndSelects 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

PropTypeDefaultDescription
colorThemeColor'primary'Active-tab accent: drives the indicator (underline / side-bar), the active tab label color, and the focus ring.
labelstring—Accessible name for the tablist (→ aria-label). WAI-ARIA recommends naming a tablist, especially with several on a page.
modelValuestring | numberfirst non-disabled tab valueActive tab value (v-model). Auto-defaults to the first non-disabled tab; self-heals if the value becomes invalid.
orientationOrientation'horizontal'Layout + keyboard axis. horizontal = row tablist with underline; vertical = column tablist with right side-bar.
tabsTabItem[]— (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:

ts
import type { TabItem } from '@oriui/vue'

const tabs: TabItem[] = [
    { value: 'overview', label: 'Overview' },
    { value: 'specs', label: 'Specs', disabled: true }
]

Events & attributes

EventPayloadDescription
update:modelValuestring | numberEmitted 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

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