Keyboard shortcut: Ctrl + K
Get started

Tabs

A bar of tabs and the panels they show. The panels are an optional slot, so the same component serves as a plain bar or a segmented control when there is nothing to reveal.

Usage

vue
What the project is for.
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabPanel, VTabs } from 'vectis-ui'

const tab = ref('overview')
</script>

<template>
  <VTabs v-model="tab" label="Project">
    <VTab value="overview" label="Overview" />
    <VTab value="activity" label="Activity" />
    <template #panels>
      <VTabPanel value="overview">What the project is for.</VTabPanel>
      <VTabPanel value="activity">What has happened lately.</VTabPanel>
    </template>
  </VTabs>
</template>

Examples

Variants and tones

variant sets the track and the frame at once: flat draws a rule under the row and underlines the selected tab, outlined puts that same bar and its panels inside a card, inset drops the row into a hollow track. tone colours the selected tab and nothing else, with the three values a button offers.

vue

flat

outlined

inset

accent

neutral

danger

<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs, type TabsTone, type TabsVariant } from 'vectis-ui'

const variants: TabsVariant[] = ['flat', 'outlined', 'inset']
const tones: TabsTone[] = ['accent', 'neutral', 'danger']

const selectedByVariant = ref<Record<string, string>>({
  flat: 'overview',
  outlined: 'overview',
  inset: 'overview',
})
const selectedByTone = ref<Record<string, string>>({
  accent: 'overview',
  neutral: 'overview',
  danger: 'overview',
})
</script>

<template>
  <div class="demo">
    <!-- Two decisions in one word. `flat` draws a rule under the tabs and underlines
         the selected one; `outlined` puts that same bar and its panels inside a card;
         `inset` turns the row into a segmented control in a hollow track. -->
    <div v-for="value in variants" :key="value" class="row">
      <p class="caption">{{ value }}</p>
      <VTabs v-model="selectedByVariant[value]" :variant="value" :label="`Project, ${value}`">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
        <VTab value="settings" label="Settings" />
      </VTabs>
    </div>

    <!-- The tone colours the selected tab and nothing else: the others stay neutral
         whatever it says, since only one of them is making a claim. -->
    <div v-for="value in tones" :key="value" class="row">
      <p class="caption">{{ value }}</p>
      <VTabs v-model="selectedByTone[value]" :tone="value" :label="`Project, ${value}`">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
        <VTab value="settings" label="Settings" />
      </VTabs>
    </div>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
}
.row {
  display: grid;
  gap: var(--vectis-space-2);
}
.caption {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

Sizes

size takes the scale every control shares, 24 to 56 pixels, and compact takes 4px off it. It is set once on the bar.

vue

xs

sm

md

lg

xl

md compact

<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs, type TabsSize } from 'vectis-ui'

const sizes: TabsSize[] = ['xs', 'sm', 'md', 'lg', 'xl']
const selected = ref<Record<string, string>>({
  xs: 'overview',
  sm: 'overview',
  md: 'overview',
  lg: 'overview',
  xl: 'overview',
  compact: 'overview',
})
</script>

<template>
  <div class="demo">
    <!-- The scale shared by every control, set once on the bar: each tab is a button
         of that size, so a tab and a button beside it line up. -->
    <div v-for="size in sizes" :key="size" class="row">
      <p class="caption">{{ size }}</p>
      <VTabs v-model="selected[size]" :size="size" :label="`Project, ${size}`">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
      </VTabs>
    </div>

    <!-- compact takes 4px off, as everywhere else. -->
    <div class="row">
      <p class="caption">md compact</p>
      <VTabs v-model="selected.compact" size="md" compact label="Project, compact">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
      </VTabs>
    </div>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: grid;
  gap: var(--vectis-space-2);
}
.caption {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

What a tab holds

A tab takes a label, an icon at either end through iconStart and iconEnd, drawn filled under iconFilled, or the default slot for what a string cannot hold. A tab reduced to its icon still needs a label, which is then its accessible name.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VBadge, VTab, VTabs } from 'vectis-ui'
import { description, notifications, schedule, search } from 'vectis-ui/icons'

const tab = ref('files')
</script>

<template>
  <VTabs v-model="tab" label="Workspace">
    <!-- A label, an icon at either end, or both. -->
    <VTab value="files" label="Files" :icon-start="description" />
    <VTab value="recent" label="Recent" :icon-start="schedule" />

    <!-- An icon on its own still has to say what it is: with no label there is no
         accessible name, so one is given here. -->
    <VTab value="search" :icon-start="search" aria-label="Search" />

    <!-- The default slot replaces the label, for anything a string cannot hold. -->
    <VTab value="alerts">
      <VBadge :count="3">Alerts</VBadge>
    </VTab>
    <VTab value="muted" label="Muted" :icon-end="notifications" />
  </VTabs>
</template>

Panels

A hidden panel is hidden and not destroyed, so what it holds keeps its state and a field inside it is still submitted. lazy holds the content back until the panel is opened once. Leaving the #panels slot out renders no panel area at all, and it has to be there or absent from the start. Once it is there, every tab needs its panel. A panel may hold another VTabs, which keeps its own layout.

vue
What the project is for.
<script setup lang="ts">
import { ref } from 'vue'
import { VInput, VTab, VTabPanel, VTabs } from 'vectis-ui'

const withPanels = ref('overview')
const bare = ref('day')
</script>

<template>
  <div class="demo">
    <!-- A hidden panel is hidden and not destroyed: what it holds keeps its state, and
         a field inside it is still submitted with the form. `lazy` is the exception,
         and only for the first showing: it holds the content back until the panel is
         opened once, then keeps it like the others. -->
    <VTabs v-model="withPanels" label="Project">
      <VTab value="overview" label="Overview" />
      <VTab value="details" label="Details" />
      <VTab value="history" label="History" />
      <template #panels>
        <VTabPanel value="overview">What the project is for.</VTabPanel>
        <VTabPanel value="details">
          <VInput label="Reference" model-value="INV-2481" />
          <p class="note">Type here, switch tab and come back: the value is still there.</p>
        </VTabPanel>
        <VTabPanel value="history" lazy>Built the first time this tab is opened.</VTabPanel>
      </template>
    </VTabs>

    <!-- Leave the slot out and no panel area is rendered at all: the same component is
         then a plain bar, or a segmented control switching a view that lives
         somewhere else on the page. -->
    <VTabs v-model="bare" variant="inset" label="Range">
      <VTab value="day" label="Day" />
      <VTab value="week" label="Week" />
      <VTab value="month" label="Month" />
    </VTabs>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
}
.note {
  margin: var(--vectis-space-3) 0 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

Alignment

align says where the tabs sit along the bar when they do not fill it. It is set on the bar rather than on the list of tabs.

vue

start

center

end

<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs, type TabsAlign } from 'vectis-ui'

const aligns: TabsAlign[] = ['start', 'center', 'end']
const selected = ref<Record<string, string>>({
  start: 'overview',
  center: 'overview',
  end: 'overview',
})
</script>

<template>
  <div class="demo">
    <!-- Where the tabs sit when they do not fill the bar. It is set on the bar and not
         on the list of tabs: pushing an overflowing list around would put whatever ran
         past the start edge out of reach. -->
    <div v-for="align in aligns" :key="align" class="row">
      <p class="caption">{{ align }}</p>
      <VTabs v-model="selected[align]" :align="align" :label="`Project, ${align}`">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
        <VTab value="settings" label="Settings" />
      </VTabs>
    </div>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: grid;
  gap: var(--vectis-space-2);
}
.caption {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

Filling the bar

fullWidth shares the whole bar between the tabs in equal parts, a label too long for its share being truncated. It is incompatible with scrolling by construction.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs } from 'vectis-ui'

const tab = ref('day')
const wide = ref('overview')
</script>

<template>
  <div class="demo">
    <!-- The tabs share the whole bar between them, in equal parts whatever their
         labels are worth. It is what turns a short row into a segmented control that
         spans its container. -->
    <VTabs v-model="tab" variant="inset" full-width label="Range">
      <VTab value="day" label="Day" />
      <VTab value="week" label="Week" />
      <VTab value="month" label="Month" />
    </VTabs>

    <!-- A label too long for its share is truncated rather than allowed to widen it.
         Filling the bar and scrolling are incompatible by construction: tabs told to fill the
         bar can never overflow it. -->
    <VTabs v-model="wide" full-width label="Project">
      <VTab value="overview" label="Overview" />
      <VTab value="activity" label="Activity and recent changes" />
      <VTab value="settings" label="Settings" />
    </VTabs>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
  max-inline-size: 28rem;
}
</style>

Orientation

orientation set to vertical runs the tabs down the side, the panels sitting beside them and the arrow keys following the axis.

vue
What the project is for.
What the project is for.
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabPanel, VTabs } from 'vectis-ui'

const flat = ref('overview')
const framed = ref('overview')
</script>

<template>
  <div class="demo">
    <!-- Down the side instead of across: the panels then sit beside the tabs rather
         than under them, and the arrow keys follow the axis. -->
    <VTabs v-model="flat" orientation="vertical" label="Project">
      <VTab value="overview" label="Overview" />
      <VTab value="activity" label="Activity" />
      <VTab value="settings" label="Settings" />
      <template #panels>
        <VTabPanel value="overview">What the project is for.</VTabPanel>
        <VTabPanel value="activity">What has happened lately.</VTabPanel>
        <VTabPanel value="settings">How it is configured.</VTabPanel>
      </template>
    </VTabs>

    <!-- Framed, the rule between the tabs and the panels moves to the other edge of
         the column: the frame already draws the outer one, and what is missing is the
         boundary between the two halves. -->
    <VTabs v-model="framed" orientation="vertical" variant="outlined" label="Project, framed">
      <VTab value="overview" label="Overview" />
      <VTab value="activity" label="Activity" />
      <VTab value="settings" label="Settings" />
      <template #panels>
        <VTabPanel value="overview">What the project is for.</VTabPanel>
        <VTabPanel value="activity">What has happened lately.</VTabPanel>
        <VTabPanel value="settings">How it is configured.</VTabPanel>
      </template>
    </VTabs>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
}
</style>

Scrolling

Too many tabs for the room and the bar scrolls, by touch, by trackpad and from the keyboard. The container needs a minimum size of zero, or the list widens its parent instead of overflowing.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs } from 'vectis-ui'

const cities = ['Paris', 'Lisbon', 'Berlin', 'Madrid', 'Rome', 'Vienna', 'Prague', 'Oslo']
const tab = ref('Paris')
</script>

<template>
  <!-- Too many tabs for the room, and the bar scrolls: by touch, by trackpad, and from
       the keyboard, where the arrow keys bring the tab they reach back into view. The
       scrollbar itself is hidden, the tabs running off the edge being the cue. -->
  <div class="narrow">
    <VTabs v-model="tab" label="Cities">
      <VTab v-for="city in cities" :key="city" :value="city" :label="city" />
    </VTabs>
  </div>
</template>

<style scoped>
.narrow {
  /* A grid or flex item does not shrink below its content unless told to, so without
     this the list would simply widen its container instead of overflowing. */
  min-inline-size: 0;
  max-inline-size: 26rem;
}
</style>

Scroll buttons

scrollButtons adds a button at each end of the bar, each disabled once that end is reached. A button that held the keyboard focus hands it to the opposite one as it disables itself. It is opt-in, and excludes fullWidth.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs } from 'vectis-ui'

const cities = ['Paris', 'Lisbon', 'Berlin', 'Madrid', 'Rome', 'Vienna', 'Prague', 'Oslo']
const horizontal = ref('Paris')
const vertical = ref('Paris')
</script>

<template>
  <div class="demo">
    <!-- A button at each end, disabled once that end is reached, for a pointer that has
         no wheel to scroll with. They are opt-in: on a bar that never overflows they
         would be two permanently disabled controls. -->
    <div class="narrow">
      <VTabs v-model="horizontal" scroll-buttons label="Cities">
        <VTab v-for="city in cities" :key="city" :value="city" :label="city" />
      </VTabs>
    </div>

    <!-- Vertically the buttons move to the top and bottom of the column, and their
         icons follow the axis without being asked. -->
    <div class="short">
      <VTabs v-model="vertical" orientation="vertical" scroll-buttons label="Cities, vertical">
        <VTab v-for="city in cities" :key="city" :value="city" :label="city" />
      </VTabs>
    </div>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
}
.narrow {
  min-inline-size: 0;
  max-inline-size: 26rem;
}
.short {
  block-size: 14rem;
}
</style>

Custom arrows

prevIcon and nextIcon replace the arrows, whose defaults follow the orientation. prevLabel and nextLabel are what a screen reader reads for the two buttons, and fall back to the dictionary.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabs } from 'vectis-ui'
import { arrow_left_alt as arrowLeftAlt, arrow_right_alt as arrowRightAlt } from 'vectis-ui/icons'

const cities = ['Paris', 'Lisbon', 'Berlin', 'Madrid', 'Rome', 'Vienna', 'Prague', 'Oslo']
const tab = ref('Paris')
</script>

<template>
  <!-- The default icons follow the orientation, chevrons across and carets down, so
       there is nothing to set on either axis. Replace them when the surrounding design
       asks for another glyph; the labels are what a screen reader reads, and they fall
       back to the dictionary in the current language. -->
  <div class="narrow">
    <VTabs
      v-model="tab"
      scroll-buttons
      :prev-icon="arrowLeftAlt"
      :next-icon="arrowRightAlt"
      prev-label="Earlier cities"
      next-label="Later cities"
      label="Cities"
    >
      <VTab v-for="city in cities" :key="city" :value="city" :label="city" />
    </VTabs>
  </div>
</template>

<style scoped>
.narrow {
  min-inline-size: 0;
  max-inline-size: 26rem;
}
</style>

Selecting on arrival

activation is manual by default: an arrow key moves the focus, and Enter or Space selects. Automatic selects the tab the focus reaches, which suits a panel that appears instantly.

vue

manual, the default

What the project is for.

automatic

What the project is for.
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabPanel, VTabs } from 'vectis-ui'

const manual = ref('overview')
const automatic = ref('overview')
</script>

<template>
  <div class="demo">
    <!-- Manual by default: an arrow key moves the focus and Enter or Space selects.
         That is what a panel costing a request needs, since otherwise every tab passed
         over on the way would fire one. -->
    <div class="row">
      <p class="caption">manual, the default</p>
      <VTabs v-model="manual" label="Project, manual">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
        <VTab value="settings" label="Settings" />
        <template #panels>
          <VTabPanel value="overview">What the project is for.</VTabPanel>
          <VTabPanel value="activity">What has happened lately.</VTabPanel>
          <VTabPanel value="settings">How it is configured.</VTabPanel>
        </template>
      </VTabs>
    </div>

    <!-- Selecting on arrival is what the ARIA authoring practices recommend when the
         panel appears instantly: the reader hears the panel as they walk the row,
         instead of having to confirm each one. Focus a tab and press an arrow key. -->
    <div class="row">
      <p class="caption">automatic</p>
      <VTabs v-model="automatic" activation="automatic" label="Project, automatic">
        <VTab value="overview" label="Overview" />
        <VTab value="activity" label="Activity" />
        <VTab value="settings" label="Settings" />
        <template #panels>
          <VTabPanel value="overview">What the project is for.</VTabPanel>
          <VTabPanel value="activity">What has happened lately.</VTabPanel>
          <VTabPanel value="settings">How it is configured.</VTabPanel>
        </template>
      </VTabs>
    </div>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-6);
}
.row {
  display: grid;
  gap: var(--vectis-space-2);
}
.caption {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

Disabled tabs

A disabled tab stops responding, greys out through the colour tokens and is stepped over by the arrow keys. Do not leave the value pointing at it, or the bar has no tab stop at all. disabled on VTabs switches off every tab and the scroll buttons at once, the panel on show staying on show.

vue
What the project is for.
<script setup lang="ts">
import { ref } from 'vue'
import { VTab, VTabPanel, VTabs } from 'vectis-ui'

const tab = ref('overview')
</script>

<template>
  <!-- A disabled tab is a disabled button: it stops responding, greys out through the
       colour tokens rather than through an opacity, and the arrow keys step over it as
       if it were not in the row. Its panel is simply never shown. -->
  <VTabs v-model="tab" label="Project">
    <VTab value="overview" label="Overview" />
    <VTab value="activity" label="Activity" disabled />
    <VTab value="settings" label="Settings" />
    <template #panels>
      <VTabPanel value="overview">What the project is for.</VTabPanel>
      <VTabPanel value="activity">Never reached.</VTabPanel>
      <VTabPanel value="settings">How it is configured.</VTabPanel>
    </template>
  </VTabs>
</template>

API

Props

VTabs
PropTypeDefault
variantTabsVariant'flat' | 'outlined' | 'inset''flat'
How the bar is framed. flat draws nothing but a rule under the tabs, with the selected one underlined; outlined puts that same bar and its panels inside a card; inset turns the row into a segmented control sitting in a hollow track.
toneTabsTone'accent' | 'neutral' | 'danger''accent'
The colour the selected tab takes. The others stay neutral whatever this says.
sizeTabsSize'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
The height of the tabs, from the scale shared by every control.
compactbooleanfalse
Takes 4px off the height of every tab.
orientationTabsOrientation'horizontal' | 'vertical''horizontal'
Whether the tabs run across the page or down its side.
alignTabsAlign'start' | 'center' | 'end''start'
Where the tabs sit along the bar when they do not fill it.
fullWidthbooleanfalse
Makes the tabs share the whole bar between them, in equal parts.
scrollButtonsbooleanfalse
Adds a button at each end of the bar to scroll it, each disabled once that end is reached. It only makes sense when the tabs can overflow, so it excludes fullWidth.
prevIconIconSourcenone
The icon of the button scrolling backwards. It follows the orientation by default.
nextIconIconSourcenone
The icon of the button scrolling forwards. It follows the orientation by default.
prevLabelstringnone
What the backward scroll button does, in words. It falls back to the dictionary.
nextLabelstringnone
What the forward scroll button does, in words. It falls back to the dictionary.
activationTabsActivation'manual' | 'automatic''manual'
Whether moving to a tab also selects it. Selecting on arrival is what the ARIA authoring practices recommend when a panel appears instantly; leave it manual when showing a panel costs a request, or every tab passed over would fire one.
disabledbooleanfalse
Makes every tab unusable, and the scroll buttons with them: the tabs leave the tab order and grey out through the colour tokens. A tab disabled on its own stays disabled either way. The panel on show stays on show.
labelstringnone
What screen readers announce for the row of tabs. It falls back to the design system dictionary.
v-modelItemValuenone
The value of the selected tab. There is deliberately no default: the component cannot know which of the tabs you wrote should open. It must name a tab that exists and is not disabled, otherwise no tab has a tab stop and the bar is unreachable from the keyboard.
VTab
PropTypeDefault
valueItemValuenone
What this tab is called in code. The panel carrying the same value is the one it shows, and it is also what the value holds when this tab is selected.
labelstringnone
The visible label. The default slot replaces it.
iconStartIconSourcenone
An icon before the label.
iconEndIconSourcenone
An icon after the label, for a count or a state the tab carries.
iconFilledbooleanfalse
Renders iconStart and iconEnd in their filled form, the font's FILL axis.
disabledbooleanfalse
Makes the tab unusable: it no longer responds, the arrow keys skip over it, and it greys out through the colour tokens. A VTabs set disabled disables every tab, this one included, whatever this says.
VTabPanel
PropTypeDefault
valueItemValuenone
Which tab shows this panel: it must be the value of one of them.
lazybooleanfalse
Holds the content back until the panel is first shown, and keeps it from then on. It is for a panel expensive to build; the state it holds is still preserved afterwards.

Slots

VTabs
SlotType
default{}
The tabs themselves.
panels{}
The panels the tabs show. Leaving it out renders no panel area at all, which is how the same component serves as a plain bar or a segmented control.
VTab
SlotType
default{}
The content of the tab, replacing the label prop.
start{}
Content before the label, which takes the place of iconStart.
end{}
Content after the label, which takes the place of iconEnd.
VTabPanel
SlotType
default{}
What the panel contains.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender
export type ItemValue = string | number

CSS variables

TokenValue
--vectis-control-size-tab-indicator2px