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.
What has happened lately.
<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.
Type here, switch tab and come back: the value is still there.
<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 has happened lately.
How it is configured.
What the project is for.
What has happened lately.
How it is configured.
<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.
What has happened lately.
How it is configured.
automatic
What the project is for.
What has happened lately.
How it is configured.
<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.
Never reached.
How it is configured.
<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
Prop
Type
Default
variant
TabsVariant'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.
tone
TabsTone'accent' | 'neutral' | 'danger'
'accent'
The colour the selected tab takes. The others stay neutral whatever this says.
size
TabsSize'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
The height of the tabs, from the scale shared by every control.
compact
boolean
false
Takes 4px off the height of every tab.
orientation
TabsOrientation'horizontal' | 'vertical'
'horizontal'
Whether the tabs run across the page or down its side.
align
TabsAlign'start' | 'center' | 'end'
'start'
Where the tabs sit along the bar when they do not fill it.
fullWidth
boolean
false
Makes the tabs share the whole bar between them, in equal parts.
scrollButtons
boolean
false
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.
The icon of the button scrolling forwards. It follows the orientation by default.
prevLabel
string
none
What the backward scroll button does, in words. It falls back to the dictionary.
nextLabel
string
none
What the forward scroll button does, in words. It falls back to the dictionary.
activation
TabsActivation'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.
disabled
boolean
false
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.
label
string
none
What screen readers announce for the row of tabs. It falls back to the design system dictionary.
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.
An icon after the label, for a count or a state the tab carries.
iconFilled
boolean
false
Renders iconStart and iconEnd in their filled form, the font's FILL axis.
disabled
boolean
false
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.
Which tab shows this panel: it must be the value of one of them.
lazy
boolean
false
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
Slot
Type
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
Slot
Type
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
Slot
Type
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.