A group of buttons driven by one value: a segmented control for one choice, or a set of filters for several. Every item is a VButton, so the tones and variants are the ones you already know.
itemVariant paints the items that are not chosen, transparent under ghost and outlined under outline. tone colours the chosen item and nothing else, with the three values a button offers.
vue
itemVariant ghost
itemVariant outline
tone accent
tone neutral
tone danger
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem, type ToggleItemVariant, type ToggleTone } from 'vectis-ui'
const itemVariants: ToggleItemVariant[] = ['ghost', 'outline']
const tones: ToggleTone[] = ['accent', 'neutral', 'danger']
const byVariant = ref<Record<string, string>>({ ghost: 'week', outline: 'week' })
const byTone = ref<Record<string, string>>({ accent: 'week', neutral: 'week', danger: 'week' })
</script>
<template>
<div class="demo">
<!-- `itemVariant` paints the items that are NOT chosen: transparent under `ghost`,
outlined under `outline`. What the chosen one takes is a separate decision. -->
<div v-for="variant in itemVariants" :key="variant" class="row">
<p class="caption">itemVariant {{ variant }}</p>
<VToggle v-model="byVariant[variant]" :item-variant="variant" :label="`Period, ${variant}`">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<!-- The tone colours the chosen item and nothing else: the rest stay neutral,
since only one of them is making a claim. -->
<div v-for="tone in tones" :key="tone" class="row">
<p class="caption">tone {{ tone }}</p>
<VToggle
v-model="byTone[tone]"
:tone="tone"
item-variant="outline"
:label="`Period, ${tone}`"
>
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
How the selection is drawn
selectedVariant is how the chosen item is painted in the group's tone: filled with solid, tinted with soft, or the colour of its text alone with ghost.
vue
solid
soft
ghost
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem, type ToggleSelectedVariant } from 'vectis-ui'
const variants: ToggleSelectedVariant[] = ['solid', 'soft', 'ghost']
const selected = ref<Record<string, string>>({ solid: 'week', soft: 'week', ghost: 'week' })
</script>
<template>
<div class="demo">
<!-- How the chosen item is drawn, in the group's tone: filled with `solid`, tinted
with `soft`, or the colour of its text alone with `ghost`. Solid is the loudest
and the safest default; ghost is for a row that must stay quiet, where the
difference rests on the text colour alone. -->
<div v-for="variant in variants" :key="variant" class="row">
<p class="caption">{{ variant }}</p>
<VToggle
v-model="selected[variant]"
:selected-variant="variant"
item-variant="outline"
:label="`Period, ${variant}`"
>
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
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 group.
vue
xs
sm
md
lg
xl
md compact
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem, type ToggleSize } from 'vectis-ui'
const sizes: ToggleSize[] = ['xs', 'sm', 'md', 'lg', 'xl']
const selected = ref<Record<string, string>>({
xs: 'week',
sm: 'week',
md: 'week',
lg: 'week',
xl: 'week',
compact: 'week',
})
</script>
<template>
<div class="demo">
<!-- The scale shared by every control, set once on the group: each item is a
button of that size, so a toggle row and a button beside it line up. -->
<div v-for="size in sizes" :key="size" class="row">
<p class="caption">{{ size }}</p>
<VToggle v-model="selected[size]" :size="size" :label="`Period, ${size}`">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<div class="row">
<p class="caption">md compact</p>
<VToggle v-model="selected.compact" size="md" compact label="Period, compact">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
What an item holds
An item takes a label, an icon at either end, or the default slot for what a string cannot hold. An item 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, VToggle, VToggleItem } from 'vectis-ui'
import {
audio_file as audioFile,
image,
info,
search,
video_file as videoFile,
} from 'vectis-ui/icons'
const kind = ref('images')
const view = ref('list')
</script>
<template>
<div class="demo">
<!-- A label, an icon at either end, or both. The icon at the end is for what the
item carries rather than what it is. -->
<VToggle v-model="kind" item-variant="outline" label="Media">
<VToggleItem value="images" label="Images" :icon-start="image" />
<VToggleItem value="video" label="Video" :icon-start="videoFile" />
<VToggleItem value="audio" label="Audio" :icon-start="audioFile" :icon-end="info" />
</VToggle>
<!-- An item reduced to its icon still has to say what it is: with no label there
is no accessible name left, so one is given here. -->
<VToggle v-model="view" item-variant="outline" label="View">
<VToggleItem value="list" :icon-start="search" aria-label="Search" />
<VToggleItem value="grid" :icon-start="image" aria-label="Thumbnails" />
<!-- The default slot replaces the label, for anything a string cannot hold. -->
<VToggleItem value="flagged">
<VBadge :count="4">Flagged</VBadge>
</VToggleItem>
</VToggle>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
</style>
Filled icons
selectedIconFilled draws the chosen item's start icon in its filled form. iconFilled on an item fills its icons whatever is chosen. Nothing happens to an icon that has no filled drawing.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
import { audio_file as audioFile, image, video_file as videoFile } from 'vectis-ui/icons'
const kind = ref('images')
</script>
<template>
<!-- The chosen item draws its icon in the filled form, a common way of reinforcing
which one is in effect, and the one that reads without relying on colour alone.
It names the icon standing FOR the item, so only the one at the start is
switched; a trailing icon keeps its outline. Nothing happens to an icon that has
no filled form, the library shipping a second drawing only where the fill really
changes the geometry. -->
<VToggle v-model="kind" selected-icon-filled item-variant="outline" label="Media">
<VToggleItem value="images" label="Images" :icon-start="image" />
<VToggleItem value="video" label="Video" :icon-start="videoFile" />
<VToggleItem value="audio" label="Audio" :icon-start="audioFile" />
</VToggle>
</template>
Detached
detached leaves the items as separate buttons with a gap between them, instead of joining them into one segmented control.
vue
joined, the default
detached
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const joined = ref('week')
const detached = ref('week')
</script>
<template>
<div class="demo">
<!-- Joined by default: the items melt into one segmented control, which is what
says they are one choice. -->
<div class="row">
<p class="caption">joined, the default</p>
<VToggle v-model="joined" item-variant="outline" label="Period, joined">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<!-- Detached leaves them as separate buttons with a gap, which suits a row of
filters that happen to share a model more than it suits one exclusive choice. -->
<div class="row">
<p class="caption">detached</p>
<VToggle v-model="detached" detached item-variant="outline" label="Period, detached">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Seamless
seamless takes out the lines drawn between two items, so the row reads as one frame holding a highlight that moves. It has no effect under detached.
vue
with the seams
seamless
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const segmented = ref('week')
const seamless = ref('week')
</script>
<template>
<div class="demo">
<!-- Joined, a line is drawn between two items: the row reads as a set of segments,
each one of them a target. -->
<div class="row">
<p class="caption">with the seams</p>
<VToggle v-model="segmented" item-variant="outline" label="Period, segmented">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<!-- Seamless takes those lines out, and the row reads as one frame holding a
highlight that moves. It has no effect under `detached`, where the items are
separate buttons already. -->
<div class="row">
<p class="caption">seamless</p>
<VToggle v-model="seamless" seamless item-variant="outline" label="Period, seamless">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Full width
fullWidth stretches the row across its parent and gives every item an equal share of that width.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const natural = ref('week')
const filling = ref('week')
</script>
<template>
<div class="column">
<VToggle v-model="natural" item-variant="outline" label="Period, as wide as its labels">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
<!-- Every item takes an equal share of the width, whatever its label measures. -->
<VToggle v-model="filling" full-width item-variant="outline" label="Period, filling the column">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</template>
<style scoped>
/* A column narrower than the page, so that filling it is something to see. */
.column {
display: flex;
flex-direction: column;
align-items: start;
gap: var(--vectis-space-3);
inline-size: 320px;
}
</style>
Elevated
elevated raises the row off the page, the shadow belonging to the row rather than to each item.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const period = ref('week')
</script>
<template>
<!-- The row is raised off the page, and the shadow belongs to the row rather than to
each item: segments overlap by a pixel, so a shadow per item would fall on its
neighbour and fill every joint with a dark band instead of lifting one object.
The whole row rises together on hover for the same reason. -->
<VToggle v-model="period" elevated item-variant="ghost" label="Period">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</template>
Orientation
orientation set to vertical stacks the items down the page, the joins and the arrow keys following the axis.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const joined = ref('week')
const detached = ref('week')
</script>
<template>
<div class="demo">
<!-- Down the page instead of across. The joins move to the horizontal edges, the
corners are carved at the top and the bottom of the column, and the arrow keys
follow the axis. -->
<VToggle v-model="joined" orientation="vertical" item-variant="outline" label="Period">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
<VToggle
v-model="detached"
orientation="vertical"
detached
item-variant="outline"
label="Period, detached"
>
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</template>
<style scoped>
.demo {
display: flex;
align-items: start;
gap: var(--vectis-space-8);
}
</style>
Choosing several
multiple lets several items be chosen at once, which makes the value a list. Clicking a chosen item gives it up again, and the array is never mutated in place.
vue
images, video
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
import {
audio_file as audioFile,
description,
image,
video_file as videoFile,
} from 'vectis-ui/icons'
// In multiple mode the value is a list, and it is never mutated in place: each change
// hands back a new array, which is what wakes a watcher bound to it.
const kinds = ref(['images', 'video'])
</script>
<template>
<div class="demo">
<!-- Several items at once, which turns the exclusive choice into a set of filters.
Clicking a chosen item gives it up again. -->
<VToggle v-model="kinds" multiple item-variant="outline" selected-icon-filled label="Media">
<VToggleItem value="images" label="Images" :icon-start="image" />
<VToggleItem value="video" label="Video" :icon-start="videoFile" />
<VToggleItem value="audio" label="Audio" :icon-start="audioFile" />
<VToggleItem value="documents" label="Documents" :icon-start="description" />
</VToggle>
<p class="value">{{ kinds.length ? kinds.join(', ') : 'Nothing selected' }}</p>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
.value {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Keeping one selected
mandatory refuses to give up the last chosen item. It is a guard and nothing more: it selects nothing on its own.
vue
one choice
several, the last one held
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const period = ref('week')
const kinds = ref(['images'])
</script>
<template>
<div class="demo">
<!-- Clicking the chosen item normally gives it up, leaving nothing selected.
`mandatory` refuses that last step, so a row that has an answer keeps one. It
is a guard and nothing more: it selects nothing on its own at the start, and a
group that begins empty stays empty until something is clicked. -->
<div class="row">
<p class="caption">one choice</p>
<VToggle v-model="period" mandatory item-variant="outline" label="Period">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<!-- With several, it is the last remaining one that cannot be given up. -->
<div class="row">
<p class="caption">several, the last one held</p>
<VToggle v-model="kinds" multiple mandatory item-variant="outline" label="Media">
<VToggleItem value="images" label="Images" />
<VToggleItem value="video" label="Video" />
<VToggleItem value="audio" label="Audio" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Disabled
disabled applies to the whole group, where nothing responds and no item takes focus, or to a single item, which the arrow keys then step over. Both grey out through the colour tokens.
vue
the group
one item
<script setup lang="ts">
import { ref } from 'vue'
import { VToggle, VToggleItem } from 'vectis-ui'
const group = ref('week')
const item = ref('day')
</script>
<template>
<div class="demo">
<!-- The whole group at once: nothing responds and no item takes focus. -->
<div class="row">
<p class="caption">the group</p>
<VToggle v-model="group" disabled item-variant="outline" label="Period, disabled">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
<!-- Or a single item, which stops responding and which the arrow keys step over as
though it were not in the row. Both grey out through the colour tokens rather
than through an opacity, so they keep their contrast. -->
<div class="row">
<p class="caption">one item</p>
<VToggle v-model="item" item-variant="outline" label="Period">
<VToggleItem value="day" label="Day" />
<VToggleItem value="week" label="Week" disabled />
<VToggleItem value="month" label="Month" />
</VToggle>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-5);
}
.row {
display: grid;
justify-items: start;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
API
Props
VToggle
Prop
Type
Default
multiple
boolean
false
Allows several items to be chosen at once, which makes the value a list.
mandatory
boolean
false
Refuses to let the last chosen item be given up, so that something is always selected once something has been. It is a guard and nothing more: it selects nothing on its own at the start.
detached
boolean
false
Leaves the items as separate buttons with a gap between them. Left out, they are joined into one segmented control.
seamless
boolean
false
Takes the lines out from between the joined items, so the row reads as one frame rather than as segments. It has no effect under detached, where the items are separate buttons already.
orientation
ToggleOrientation'horizontal' | 'vertical'
'horizontal'
Whether the items run across the page or down it.
fullWidth
boolean
false
Stretches the row across the whole inline size of its parent, every item taking an equal share of it, on the terms of VButtonGroup's own prop.
itemVariant
ToggleItemVariant'ghost' | 'outline'
'ghost'
How the unselected items are drawn. What the selected one takes is selectedVariant. It is named for the items because that is what it paints: on VTabs and VDataTable variant names the decoration of the frame instead.
selectedVariant
ToggleSelectedVariant'solid' | 'soft' | 'ghost'
'solid'
How the selected item is drawn, in the group's tone: filled with solid, tinted with soft, or the colour of its text alone with ghost.
tone
ToggleTone'accent' | 'neutral' | 'danger'
'accent'
The colour a selected item takes. The others stay neutral.
size
ToggleSize'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
The height of the items, from the scale shared by every control.
compact
boolean
false
Takes 4px off the height of every item.
elevated
boolean
false
Raises the row off the page, on the terms of VButtonGroup's own prop: the shadow belongs to the row rather than to each item, so the joints stay clear.
disabled
boolean
false
Makes the whole group unusable.
selectedIconFilled
boolean
false
Draws the selected item's icon in its filled form, a common way of reinforcing that it is the one in effect.
label
string
none
What screen readers announce for the group, "Text alignment", "Filters". It is strongly recommended: no default could say what a group of buttons is for.
What is selected, and its shape follows multiple: a single value, or null where it starts, when one item may be chosen, and an array when several may. A null or scalar value passed in multiple mode is read as an empty selection. The array is never mutated in place. Re-clicking the selected item deselects it unless mandatory is set.
An icon after the label. It is not switched to its filled form by the group selectedIconFilled, which names the icon standing for the item rather than one trailing it; iconFilled fills it for good.
iconFilled
boolean
false
Renders iconStart and iconEnd in their filled form, whether the item is selected or not. The group's selectedIconFilled still fills the start icon of the selected item when this is left out.
disabled
boolean
false
Makes this item unusable: it no longer responds, the arrow keys skip over it, and it greys out through the colour tokens.
Slots
VToggle
Slot
Type
default
{}
The items of the group.
VToggleItem
Slot
Type
default
{}
The content of the item, 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.
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.