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
<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.
<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.
Temperature, -10 to 40
19 °C
<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.
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.
<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.
<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>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.
<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>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.
<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.
<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.
<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.
<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.
<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.
<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>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.
<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
Events
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