Keyboard shortcut: Ctrl + K
Get started

Slider

A value picked by sliding, one thumb or two. It is built on real range inputs, so the keyboard, the form and assistive technology all come from the browser.

Usage

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

const volume = ref(40)
</script>

<template>
  <div class="demo">
    <VSlider v-model="volume" label="Volume" />
  </div>
</template>

<style scoped>
.demo {
  inline-size: 20rem;
}
</style>

Examples

Range

range offers two thumbs to pick a span, which makes the value a pair. The pair stays ordered, a thumb taken past its sibling pushing it along, and each is announced as the start or the end of the range.

vue

20 to 60

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

const budget = ref<[number, number]>([20, 60])
</script>

<template>
  <div class="demo">
    <!-- `range` is what settles the mode, and the model then holds a pair. A thumb taken
         past its sibling pushes it along, so the pair stays ordered whatever the reader
         does and a range closed onto a single value can still be opened again. Each thumb
         is announced as the start or the end of the range rather than as two sliders with
         the same name. -->
    <VSlider v-model="budget" range label="Budget" />
    <p class="value">{{ budget[0] }} to {{ budget[1] }}</p>
  </div>
</template>

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

Min and max

min and max bound the value, 0 and 100 unless said otherwise, negatives included. Everything else is measured against them.

vue

Temperature, -10 to 40

19 °C

Rating, 1 to 5

3 out of 5

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

const temperature = ref(19)
const rating = ref(3)
</script>

<template>
  <div class="demo">
    <!-- The bounds are 0 and 100 by default, and both may be anything, negatives
         included. -->
    <div class="row">
      <p class="caption">Temperature, -10 to 40</p>
      <VSlider v-model="temperature" :min="-10" :max="40" label="Temperature" />
      <p class="value">{{ temperature }} °C</p>
    </div>

    <!-- A short span is what makes each stop reachable from the keyboard in a few
         presses, and what makes the ticks worth drawing. -->
    <div class="row">
      <p class="caption">Rating, 1 to 5</p>
      <VSlider v-model="rating" :min="1" :max="5" ticks label="Rating" />
      <p class="value">{{ rating }} out of 5</p>
    </div>
  </div>
</template>

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

Steps

step is the gap between two values the thumb can stop on, and what an arrow key moves by. ticks marks those stops on the track, and past fifty steps no tick is drawn.

vue

step 10, with ticks

step 0.1

0.4

0 to 95 by 10, so the last stop is 90

50

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

const quantity = ref(50)
const opacity = ref(0.4)
const odd = ref(50)
</script>

<template>
  <div class="demo">
    <!-- The step is the gap between two values the thumb can stop on, and it is also
         what an arrow key moves by. `ticks` marks each of those stops on the track. -->
    <div class="row">
      <p class="caption">step 10, with ticks</p>
      <VSlider v-model="quantity" :step="10" ticks label="Quantity" />
    </div>

    <!-- A fractional step is fine: the value is rounded back onto the step, so a tenth
         does not accumulate the error floating point leaves behind. -->
    <div class="row">
      <p class="caption">step 0.1</p>
      <VSlider v-model="opacity" :min="0" :max="1" :step="0.1" ticks label="Opacity" />
      <p class="value">{{ opacity }}</p>
    </div>

    <!-- When the span does not divide evenly by the step, the last stop falls short of
         the maximum: here 0 to 95 by 10 stops at 90, and the ticks say so rather than
         drawing one where the thumb cannot go. Past fifty steps no tick is drawn at
         all, a comb that dense being unreadable. -->
    <div class="row">
      <p class="caption">0 to 95 by 10, so the last stop is 90</p>
      <VSlider v-model="odd" :max="95" :step="10" ticks label="Coverage" />
      <p class="value">{{ odd }}</p>
    </div>
  </div>
</template>

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

Text labels

labels names the stops under the track, one entry per step, and is what a screen reader announces in place of the raw number. Giving labels turns the ticks on by itself.

vue
XSSMLXL
<script setup lang="ts">
import { ref } from 'vue'
import { VSlider } from 'vectis-ui'

const size = ref(2)
</script>

<template>
  <div class="demo">
    <!-- One label per step, in order. They name the stops on the track, and they also
         become what a screen reader announces in place of the raw number: this slider
         says "M" and not "2". Giving labels turns the ticks on by itself, a label with
         no mark to sit under having nothing to point at. -->
    <VSlider v-model="size" :min="0" :max="4" :labels="['XS', 'S', 'M', 'L', 'XL']" label="Size" />
  </div>
</template>

<style scoped>
.demo {
  inline-size: 20rem;
  padding-inline: var(--vectis-space-4);
}
</style>

Icon labels

A label may carry an icon instead of a word, the label still being what is announced and what the value is read as. Both forms mix in the same list.

vue

Information

<script setup lang="ts">
import { ref } from 'vue'
import { VSlider, type SliderLabel } from 'vectis-ui'
import { check_circle as checkCircle, error, info, warning } from 'vectis-ui/icons'

const threshold = ref(1)

// A label may be an icon instead of a word, and then the words come with it: the icon
// is what the reader sees, the label what a screen reader announces.
const labels = [
  { icon: checkCircle, label: 'Silent' },
  { icon: info, label: 'Information' },
  { icon: warning, label: 'Warning' },
  { icon: error, label: 'Error' },
] satisfies SliderLabel[]
</script>

<template>
  <div class="demo">
    <VSlider v-model="threshold" :min="0" :max="3" :labels="labels" label="Alert threshold" />
    <p class="value">{{ labels[threshold]?.label }}</p>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-3);
  inline-size: 20rem;
  padding-inline: var(--vectis-space-4);
}
.value {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

Showing the value while sliding

tooltip shows a bubble above the thumb while it is dragged or holds keyboard focus, one per thumb. It is decorative and hidden from assistive technology.

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

const volume = ref(40)
const budget = ref<[number, number]>([20, 60])
</script>

<template>
  <div class="demo">
    <!-- The bubble shows the value above the thumb while it is being dragged or while
         it holds keyboard focus, which is what a track with no numbers on it needs.
         It is decorative: the value is already in the slider's own announcement, so
         nothing is said twice. -->
    <VSlider v-model="volume" tooltip label="Volume" />

    <!-- In range mode each thumb carries its own. -->
    <VSlider v-model="budget" tooltip range label="Budget" />
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-8);
  inline-size: 20rem;
  /* Room for the bubbles, which are drawn above the track. */
  padding-block-start: var(--vectis-space-6);
}
</style>

Typing the value exactly

inputs adds a number field beside the track, one per end in range mode. What is typed is committed when the field is left or on Enter, clamped and snapped to the step; an unreadable entry puts the previous value back.

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

const volume = ref(40)
const budget = ref<[number, number]>([20, 60])
</script>

<template>
  <div class="demo">
    <!-- Sliding is quick and imprecise, and this is the way out: a number field beside
         the track, one per end in range mode. What is typed is committed when the
         field is left or on Enter, never as it is typed, or the 1 of 15 would be
         clamped before the 5 was pressed. An out of bounds entry is brought back
         inside, an unreadable one puts the previous value back. -->
    <VSlider v-model="volume" inputs label="Volume" />
    <VSlider v-model="budget" inputs range label="Budget" />
  </div>
</template>

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

Field sizes

size sets the height of the number fields inputs adds, md by default as on every field. Inside a VInputGroup the row decides, as it does for its other fields.

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

const small = ref(20)
const medium = ref(40)
const large = ref(60)
</script>

<template>
  <div class="demo">
    <VSlider v-model="small" inputs size="sm" label="Small" />
    <VSlider v-model="medium" inputs label="Medium" />
    <VSlider v-model="large" inputs size="lg" label="Large" />
  </div>
</template>

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

Orientation

orientation set to vertical stands the slider up, the lowest value at the bottom. Its length comes from a token rather than from its container.

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

const volume = ref(40)
const budget = ref<[number, number]>([20, 60])
const gain = ref(60)
</script>

<template>
  <div class="demo">
    <!-- Upright, with the lowest value at the bottom. Everything else is unchanged:
         the ticks, the labels, the bubbles and the number fields all follow the axis,
         and the keyboard is still the browser's. -->
    <VSlider v-model="volume" orientation="vertical" label="Volume" />
    <VSlider v-model="budget" orientation="vertical" range tooltip label="Budget" />

    <!-- A vertical slider has no container to take its length from, so it reads a
         token instead. Override that token to make it longer or shorter. -->
    <VSlider v-model="gain" class="tall" orientation="vertical" inputs label="Gain" />
  </div>
</template>

<style scoped>
.demo {
  display: flex;
  align-items: start;
  gap: var(--vectis-space-8);
}
.tall {
  --vectis-control-size-slider-length: 16rem;
}
</style>

Disabled

disabled greys the track, the thumb and the ticks through the colour tokens, takes the thumbs out of the tab order and disables the number fields with them.

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

const volume = ref(30)
const budget = ref<[number, number]>([20, 60])
</script>

<template>
  <div class="demo">
    <!-- It greys the track, the thumb and the ticks through the colour tokens rather
         than through an opacity, takes the thumbs out of the tab order, and disables the
         number fields along with them: there is no half-usable slider whose value can
         still be typed. -->
    <VSlider v-model="volume" disabled label="Volume" />
    <VSlider v-model="budget" range :step="10" ticks inputs disabled label="Budget" />
  </div>
</template>

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

Read-only

readonly shows the value without letting it change. A range input has no native read-only, so the component cancels the keys that move a thumb and puts back a thumb the pointer has moved. The thumbs stay focusable and announced, and the number fields turn read-only with them.

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

const volume = ref(40)
const budget = ref<[number, number]>([20, 60])
</script>

<template>
  <div class="demo">
    <!-- The thumbs stay focusable and announced, but neither a key nor the pointer
         moves them, and the number fields turn read-only with them. -->
    <VSlider v-model="volume" readonly label="Volume" />
    <VSlider v-model="budget" range inputs readonly label="Budget" />
  </div>
</template>

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

Invalid

invalid rings the thumbs in the danger colour, as a checkbox colours its border, and tells assistive technology through aria-invalid on every thumb and field.

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

const quota = ref<[number, number]>([30, 90])
</script>

<template>
  <div class="demo">
    <VSlider v-model="quota" range inputs invalid label="Quota" />
  </div>
</template>

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

In a form

name, id and the aria-* are redirected onto the real range input underneath. Naming goes through the label prop, which sets an aria-label; your own aria-label or aria-labelledby takes precedence over it, and a range then names its two thumbs from whichever you gave. A range has no single value to submit: only the end thumb carries the name. hint draws a line of help under the track and is appended to your own aria-describedby rather than replacing it.

vue

Nothing submitted yet

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

const volume = ref(40)
const submitted = ref<string | null>(null)

function onSubmit(event: Event) {
  const data = new FormData(event.target as HTMLFormElement)
  submitted.value = [...data].map(([key, value]) => `${key}=${value}`).join(', ')
}
</script>

<template>
  <form class="demo" @submit.prevent="onSubmit">
    <!-- The root is a layout box, so `name`, `id` and the aria-* are redirected onto
         the real range input: left on the wrapper a name would submit nothing and a
         label would point at a div. Here the name reaches the form and the label
         reaches the thumb, which is why the `label` prop is left out: it sets an
         aria-label, and that would win over the visible label. -->
    <label class="field-label" for="volume">Volume</label>
    <VSlider id="volume" v-model="volume" name="volume" />

    <VButton class="submit" type="submit" size="sm" variant="outline" tone="neutral">
      Submit
    </VButton>
    <p class="log">{{ submitted ?? 'Nothing submitted yet' }}</p>
  </form>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-3);
  inline-size: 20rem;
}
.submit {
  justify-self: start;
}
.field-label {
  font-size: var(--vectis-text-label-size);
  font-weight: var(--vectis-text-label-weight);
}
.log {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-caption-size);
}
</style>

API

Props

PropTypeDefault
minnumber0
The lowest value the thumb can reach.
maxnumber100
The highest value the thumb can reach.
stepnumber1
The gap between two values the thumb can stop on. It is also what the arrow keys move by, and what a value typed into the companion field is snapped to.
rangebooleanfalse
Offers two thumbs to pick a range, which makes the value a pair.
disabledbooleanfalse
Makes the slider unusable.
readonlybooleanfalse
Shows the value without allowing it to be changed. The thumbs can still be focused and are announced as read-only, but neither the pointer nor the keyboard moves them, and the number fields turn read-only with them.
invalidbooleanfalse
Marks the value as invalid, which colours the thumbs and tells assistive technology so. It is for a rule the browser cannot check by itself.
sizeSliderSize'sm' | 'md' | 'lg''md'
The height of the number fields inputs adds. Inside a VInputGroup the group's size wins, as it does for every field of the row.
labelstringnone
What screen readers announce for the slider. It is an accessible name and draws nothing on screen. In range mode the two thumbs are announced as the start and the end of it.
hintstringnone
A line of help under the track, stating what the numbers mean or where they may go. It is tied to the slider for assistive technology, so it is read out after the name rather than as part of it.
orientationSliderOrientation'horizontal' | 'vertical''horizontal'
Turns the slider upright, with the lowest value at the bottom.
inputsbooleanfalse
Adds a number field beside the slider for setting the value exactly, one field or one per end in range mode. Sliding is quick but imprecise; this is the way out.
ticksbooleanfalse
Marks each step on the track. Providing labels implies it. Past fifty steps the marks would be an unreadable comb and are not drawn at all.
labelsSliderLabel[]none
A label for every step, in order: a piece of text, or an icon with the words that name it for screen readers. They also become what a screen reader announces in place of the raw number.
tooltipbooleanfalse
Shows the value in a bubble above the thumb while it is being moved or focused.
v-modelSliderValue0
The value: a single number, or an ordered pair once range is set. It is that prop and not the shape of this value that decides how many thumbs are drawn. The pair stays ordered whatever the reader does, a thumb taken past its sibling pushing it along.

Events

EventType
input[value: SliderValue]
The value is being changed: every step of a drag, and every key that moves a thumb. It carries the whole value, a pair in range mode, and fires for either thumb.
change[value: SliderValue]
The reader settled on a value: a thumb was released or moved by a key, or a number field was committed. It carries the whole value, a pair in range mode, and fires for either thumb, where the v-model follows every step of a drag.

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 SliderLabel = string | { icon: IconSource; label: string }
export type SliderValue = number | [number, number]

CSS variables

TokenValue
--vectis-control-size-slider-track0.375rem
--vectis-control-size-slider-thumb1.25rem
--vectis-control-size-slider-length10rem
--vectis-control-size-slider-field5rem