Keyboard shortcut: Ctrl + K
Get started

Toggle

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.

Usage

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

const alignment = ref('left')
</script>

<template>
  <VToggle v-model="alignment" label="Text alignment">
    <VToggleItem value="left" label="Left" />
    <VToggleItem value="center" label="Centre" />
    <VToggleItem value="right" label="Right" />
  </VToggle>
</template>

Examples

Variants and tones

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
PropTypeDefault
multiplebooleanfalse
Allows several items to be chosen at once, which makes the value a list.
mandatorybooleanfalse
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.
detachedbooleanfalse
Leaves the items as separate buttons with a gap between them. Left out, they are joined into one segmented control.
seamlessbooleanfalse
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.
orientationToggleOrientation'horizontal' | 'vertical''horizontal'
Whether the items run across the page or down it.
fullWidthbooleanfalse
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.
itemVariantToggleItemVariant'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.
selectedVariantToggleSelectedVariant'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.
toneToggleTone'accent' | 'neutral' | 'danger''accent'
The colour a selected item takes. The others stay neutral.
sizeToggleSize'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
The height of the items, from the scale shared by every control.
compactbooleanfalse
Takes 4px off the height of every item.
elevatedbooleanfalse
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.
disabledbooleanfalse
Makes the whole group unusable.
selectedIconFilledbooleanfalse
Draws the selected item's icon in its filled form, a common way of reinforcing that it is the one in effect.
labelstringnone
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.
v-modelToggleModelValuenull
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.
VToggleItem
PropTypeDefault
valueToggleValuenone
What choosing this item means. It is what the group's value holds when the item is selected, and it must be unique within the group.
labelstringnone
The visible label. The default slot replaces it.
iconStartIconSourcenone
An icon before the label.
iconEndIconSourcenone
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.
iconFilledbooleanfalse
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.
disabledbooleanfalse
Makes this item unusable: it no longer responds, the arrow keys skip over it, and it greys out through the colour tokens.

Slots

VToggle
SlotType
default{}
The items of the group.
VToggleItem
SlotType
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.

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
export type ToggleModelValue = ToggleValue | ToggleValue[] | null
export type ToggleValue = ItemValue