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 colour; disabled is the native button attribute.
The examples are organised 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 · warn · 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 colour 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. |
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 / _warn / _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 named slot matching the tab's value to give each panel its own markup. A scoped #default="{ tab }" slot is the fallback for shared/uniform content across all panels.
<!-- 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 behaviour.
- Full WAI-ARIA Tabs pattern, driven by the headless
useTabscomposable (the shared Vue + Svelte behaviour).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 colour 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. |
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. (Svelte bindings are planned.)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | 'primary' | Active-tab accent: drives the indicator (underline / side-bar), the active tab label colour, 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 | 'horizontal' | 'vertical' | 'horizontal' | Layout + keyboard axis. horizontal = row tablist with underline; vertical = column tablist with right side-bar. |
tabs | Array<{ value: string | number; label: string; disabled?: boolean }> | — (required) | The set of tabs. Each item: value (unique key), label (visible text), optional disabled. |
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. |
<value> | { tab: { value: string | number; label: string; disabled?: boolean } } | Primary. A per-value named slot — name the slot by the tab's value (e.g. #account for value 'account'). Numeric values are stringified for the slot name. |
default | { tab: { value: string | number; label: string; disabled?: boolean } } | Fallback. A scoped default slot rendered for any tab that has no matching named slot — one shared template for uniform content. Read the per-panel tab from scope. |