Skip to content

Tabs ​

Tabs show one panel at a time from a set that belongs together. A TabsTab and its TabsPanel are paired by id, so the order they appear in the markup does not have to match.

ts
import { Tabs, TabsList, TabsPanel, TabsTab } from "ropav";
variant
align
orientation
keyboardActivation
template
<Tabs class="w-full max-w-md">
  <TabsListContainer>
    <TabsList aria-label="Project settings">
      <TabsTab id="activity">
        Activity
        <TabsIndicator />
      </TabsTab>
      <TabsTab id="access">
        Access
        <TabsIndicator />
      </TabsTab>
    </TabsList>
  </TabsListContainer>
  <TabsPanel class="pt-4" id="activity">Everything that happened while you were away.</TabsPanel>
  <TabsPanel class="pt-4" id="access">Who can see this, and what they can do.</TabsPanel>
</Tabs>

A tab list and its panels ​

TabsListContainer is what scrolls when the tabs do not fit; TabsList is the row itself. TabsIndicator goes inside each tab rather than beside the list, so the marker travels with the tab it belongs to.

An icon before the label and a Chip after it go straight into the TabsTab: it lays them out on one line and sizes the icon, and the count joins the name the tab reads out.

tabs-basic.vue
vue
<script setup lang="ts">
import { Tabs, TabsIndicator, TabsList, TabsListContainer, TabsPanel, TabsTab } from "ropav";

const items = [
  { body: "Everything that happened while you were away.", id: "activity", label: "Activity" },
  { body: "Who can see this, and what they can do.", id: "access", label: "Access" },
  { body: "Keys, webhooks and the rest of the plumbing.", id: "advanced", label: "Advanced" },
];
</script>

<template>
  <div class="panel">
    <Tabs>
      <TabsListContainer>
        <TabsList aria-label="Project settings">
          <TabsTab v-for="item in items" :id="item.id" :key="item.id">
            {{ item.label }}
            <TabsIndicator />
          </TabsTab>
        </TabsList>
      </TabsListContainer>

      <TabsPanel v-for="item in items" :id="item.id" :key="item.id" class="tab-panel">
        {{ item.body }}
      </TabsPanel>
    </Tabs>
  </div>
</template>

<style scoped>
.panel {
  width: 100%;
  max-width: var(--rp-container-md);
}

.tab-panel {
  padding-top: calc(var(--rp-spacing) * 4);
}
</style>

Width ​

A horizontal list shares its row between the tabs, so every tab is as wide as the row divided by the number of tabs. :full-width="false" sizes each tab to its label instead and starts the row at the inline start, which is the layout of a few tabs at the head of a bar. The primary track hugs the tabs along with them; the secondary rule still runs the whole row, because it is the edge of the bar rather than of the tabs.

Either way the list grows with its labels, so a row that outgrows its container scrolls and shows its chevrons as before. A vertical list is unaffected: its tabs always take the widest one's width.

tabs-header.vue
vue
<script setup lang="ts">
import { Tabs, TabsIndicator, TabsList, TabsListContainer, TabsPanel, TabsTab } from "ropav";

const items = [
  { body: "What the change does, and why.", id: "description", label: "Description" },
  { body: "Three people have weighed in.", id: "comments", label: "Comments" },
  { body: "Every revision since it was opened.", id: "history", label: "History" },
];
</script>

<template>
  <div class="panel">
    <Tabs :full-width="false" variant="secondary">
      <TabsListContainer>
        <TabsList aria-label="Pull request">
          <TabsTab v-for="item in items" :id="item.id" :key="item.id">
            {{ item.label }}
            <TabsIndicator />
          </TabsTab>
        </TabsList>
      </TabsListContainer>

      <TabsPanel v-for="item in items" :id="item.id" :key="item.id" class="tab-panel">
        {{ item.body }}
      </TabsPanel>
    </Tabs>
  </div>
</template>

<style scoped>
.panel {
  width: 100%;
  max-width: var(--rp-container-md);
}

.tab-panel {
  padding-top: calc(var(--rp-spacing) * 4);
}
</style>

Alignment ​

A tab that shares the row is wider than its label, so the label sits in the middle of that space; align moves the label to either end instead, which is usually what a vertical deck wants. A tab sized to its label has no space to move it in.

Activation ​

keyboard-activation decides what the arrow keys do. The default follows focus — moving to a tab selects it — which is right when switching is instant. Set it to manual when a panel costs something to show, so the reader arrows to the tab they want and presses Enter.

Accessibility ​

  • The tab list is one stop in the tab order. Arrow keys move between the tabs inside it, and Home and End jump to the ends.
  • aria-label on TabsList names the set. Give it one whenever the surrounding heading does not already say what the tabs are for.
  • Each panel is labelled by its tab, so a reader who moves into the panel hears which one they are in.

API ​

Tabs

PropTypeDefaultDescription
align"start" | "center" | "end""center"Where a tab's label and icon sit inside the tab.
defaultSelectedKeyCollectionKey—The initially selected tab. Falls back to the first tab that is not disabled.
disabledKeysCollectionKey[]—
fullWidthbooleantrueWhether the tabs share the row between them. false sizes each tab to its label and starts the row at the inline start, which is the header-bar layout; the primary track then hugs the tabs too. A vertical list is unaffected — its tabs always take the widest one's width.
idstring—A base the tab and panel ids are derived from. Deliberately does not reach the DOM, so it cannot collide with an element the caller ids for itself.
isDisabledboolean—
keyboardActivation"automatic" | "manual""automatic"
orientation"horizontal" | "vertical""horizontal"
selectedKeyCollectionKey—The selected tab, when the caller drives it.
variant"primary" | "secondary"—

The default slot receives:

Slot propType
isFocusVisibleboolean
isFocusWithinboolean
orientationTabsOrientation
EventPayload
selectionChangeCollectionKey
update:selectedKeyCollectionKey

TabsIndicator

TabsIndicator takes nothing but class.

TabsList

PropTypeDefaultDescription
ariaLabelstring—Names the tab list. Declared under this name because Vue normalises prop names, so a hyphenated one never resolves — it is mapped back to aria-label at the binding.

Slots: default.

TabsListContainer

Slots: default.

TabsPanel

PropTypeDefaultDescription
id*CollectionKey—Matches the id of the tab this panel belongs to.
shouldForceMountboolean—Keeps the panel mounted while another tab is selected.

The default slot receives:

Slot propType
isEnteringboolean
isExitingboolean
isFocusedboolean
isFocusVisibleboolean

TabsSeparator

TabsSeparator takes nothing but class.

TabsTab

PropTypeDefaultDescription
id*CollectionKey—Identifies the tab, and the panel that goes with it.
isDisabledboolean—
textValuestring—What typeahead and assistive technology read, when the rendered text will not do.

The default slot receives:

Slot propType
isDisabledboolean
isFocusedboolean
isFocusVisibleboolean
isHoveredboolean
isPressedboolean
isSelectedboolean