Slider
A track and one or more thumbs, for a number whose bounds are known in advance.
Slider picks a number, or a range of them, from a bounded interval, with one
thumb per value. Useful when the bounds matter more than the exact figure; where
the user needs to type a precise number, use Input.
<template>
<Slider.Root
v-model="volume"
class="w-64"
:options="{ min: 0, max: 100, step: 1, largeStep: 10 }"
>
<div class="mb-3 flex items-baseline justify-between">
<span class="type-component-lg text-primary-solid">Volume</span>
<Slider.Value
class="type-component-sm -number text-primary-muted tabular-nums"
/>
</div>
<Slider.Control
class="flex h-8 w-full items-center px-[0.46875rem] [--mirror-slider-cursor:grab]"
>
<Slider.Track
class="rounded-component-round bg-primary-subtle data-[disabled=true]:bg-disabled-subtle w-full transition-all duration-100 ease-linear"
>
<Slider.Indicator
class="rounded-component-round bg-primary-solid data-[disabled=true]:bg-disabled-solid transition-colors duration-100 ease-linear"
/>
</Slider.Track>
<Slider.Thumb
aria-label="Volume"
class="group/thumb absolute [--mirror-slider-thumb-size:0.9375rem] focus-visible:outline-none"
>
<span
class="rounded-component-lg bg-primary-solid group-data-[disabled=true]/thumb:bg-disabled-solid absolute top-1/2 left-1/2 size-[0.9375rem] -translate-x-1/2 -translate-y-1/2 outline-4 outline-transparent transition-all duration-100 ease-linear group-focus-visible/thumb:outline-4 group-focus-visible/thumb:outline-[color:var(--app-color-focus-outline)] group-data-[dragging=true]/thumb:size-[1.0625rem] group-data-[dragging=true]/thumb:outline-none!"
/>
</Slider.Thumb>
</Slider.Control>
</Slider.Root>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Slider } from '@maas/mirror/vue'
const volume = ref(60)
</script>
Usage guidelines
- The positioning maths reads
--mirror-slider-thumb-size, so size the thumb with that variable; awidthalone leaves both ends off by half a thumb. - An array value is clamped and sorted ascending before anything derives from it, so a model that arrives out of order still renders on the track.
- Make sure the slider has a label, either through a
Labelinside aFieldor anaria-labelon each thumb. A range slider names its two thumbs “start range” and “end range” on top of that.
Anatomy
Assemble the parts, one Slider.Thumb per value.
| Name | Required | Description |
|---|---|---|
| Slider.Root | true | Renders a <div role="group"> plus one visually hidden <input type="range"> per thumb. |
| Slider.Value | Renders an <output> with the formatted value. | |
| Slider.Control | true | Owns the pointer interaction. |
| Slider.Track | true | The box the indicator and the thumbs position themselves against, and the box the pointer maths measures. |
| Slider.Indicator | Sized to the filled portion. | |
| Slider.Thumb | true | One per value. |
<script setup lang="ts">
import { Field, Label, Slider } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'volume' }">
<Label :native-label="false">Volume</Label>
<Slider.Root v-model="volume" :options="{ min: 0, max: 100, step: 1 }">
<Slider.Value />
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb />
</Slider.Track>
</Slider.Control>
</Slider.Root>
</Field.Root>
</template>
Examples
Range values
An array value makes it a range slider, and the anatomy does not change: one
Slider.Thumb per entry.
<template>
<Slider.Root v-model="range" :options="{ min: 0, max: 100 }">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb v-for="(_, index) in range" :key="index" />
</Slider.Track>
</Slider.Control>
</Slider.Root>
</template>
<script setup lang="ts">
const range = ref([20, 80])
</script>
options.minStepsBetweenThumbs widens the bound between neighbours into a gap,
counted in steps rather than in units.
<template>
<Slider.Root
v-model="range"
:options="{ step: 5, minStepsBetweenThumbs: 2 }"
/>
</template>
options.thumbCollisionBehavior decides what a dragged thumb does when it
reaches its neighbour. It pushes the neighbour along by default, swap hands
the drag to the neighbour it passed, and none stops it dead. The keyboard
always stops at the neighbour, whatever the setting.
<template>
<Slider.Root
v-model="range"
:options="{ thumbCollisionBehavior: 'swap' }"
/>
</template>
Step and large step
Values snap to min + n * step and clamp to the bounds, on the keyboard and
under the pointer alike, and a fractional options.step keeps its precision.
<template>
<Slider.Root v-model="rate" :options="{ min: 0, max: 1, step: 0.1 }" />
</template>
options.largeStep is the granularity of PageUp, PageDown and Shift plus
an arrow, so set it only where the coarse jump should not be ten notches.
<template>
<Slider.Root
v-model="year"
:options="{ min: 1900, max: 2100, step: 1, largeStep: 25 }"
/>
</template>
Orientation and direction
options.orientation decides the axis the track lays out on and the axis the
pointer is read from, and a vertical slider is read bottom-up.
<template>
<Slider.Root v-model="volume" :options="{ orientation: 'vertical' }">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb />
</Slider.Track>
</Slider.Control>
</Slider.Root>
</template>
options.dir mirrors the horizontal axis for both the pointer and the arrow
keys, and is rendered onto the root so the parts’ logical properties follow it.
Unset, it follows a DirectionProvider or the surrounding dir attribute, as
described in Composition.
Formatting the value
Sliders render the raw number. options.format and options.locale run it
through Intl.NumberFormat, for Slider.Value and for what each thumb
announces alike, so the readout and the announcement never drift apart.
<template>
<Slider.Root
v-model="rate"
:options="{
min: 0,
max: 1,
step: 0.01,
format: { style: 'percent' },
locale: 'en-GB',
}"
>
<Slider.Value />
</Slider.Root>
</template>
A range renders both entries joined by an en dash. If you want something else entirely, take the default slot, which hands over the entries one by one as well as joined.
<template>
<Slider.Value v-slot="{ formatted, formattedValues }">
{{ formattedValues.join(' to ') || formatted }}
</Slider.Value>
</template>
Naming the thumbs
A range slider announces 20 start range and 80 end range, so the two thumbs
are told apart under one field label. options.getAriaLabel and
options.getAriaValueText replace that, and both receive the thumb’s index, so
one function can still name each thumb differently.
<template>
<Slider.Root
v-model="range"
:options="{
getAriaLabel: (index) => (index === 0 ? 'Minimum' : 'Maximum'),
getAriaValueText: (formatted) => `${formatted} pounds`,
}"
/>
</template>
Disabling one thumb
options.disabled blocks the whole control. disabled on a Slider.Thumb
blocks that thumb alone: it leaves the tab order, ignores the keyboard and is
skipped when a press looks for the nearest thumb.
<template>
<Slider.Root v-model="range">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb disabled />
<Slider.Thumb />
</Slider.Track>
</Slider.Control>
</Slider.Root>
</template>
Reacting to changes
update:modelValue fires on every change, a drag included, while valueCommit
fires once the interaction ends, so render from the first and persist from the
second.
<template>
<Slider.Root
v-model="volume"
@value-commit="save"
@dragging="scrubbing = $event"
/>
</template>
valueCommit fires on the release of a movement key that moved something, so a
key pressed against a bound commits nothing. dragging brackets a pointer drag
and never fires for a keyboard change.
Decorating a thumb
The thumb’s position lives in a component-local variable, so take the
percentage or value slot prop and put the bubble inside the thumb it belongs
to.
<template>
<Slider.Root v-model="volume">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb v-slot="{ value }">
<span class="bubble">{{ value }}</span>
</Slider.Thumb>
</Slider.Track>
</Slider.Control>
</Slider.Root>
</template>
Inside a form
Slider.Root renders one visually hidden range input per thumb, each with
name, min, max, step and the current value, so
the form sees it.
<template>
<form @submit.prevent="submit">
<Slider.Root
v-model="range"
:options="{ name: 'range', required: true }"
/>
</form>
</template>
options.form points the hidden inputs at a form by id, which is how a slider
rendered outside the <form> still submits with it.
<template>
<Slider.Root
v-model="range"
:options="{ name: 'range', form: 'settings' }"
/>
</template>
The thumbs are the only tab stops, so they report focus to a Field and
blurring one marks the field touched.
Reaching the slider from anywhere
Give the root an id and useMirrorSlider(id) reads and writes that slider
from anywhere in the app.
<template>
<Button @click="setValue(0)">Mute</Button>
</template>
<script setup lang="ts">
const { value, percentages, dragging, setValue, setValueAt, focusThumb } =
useMirrorSlider(SliderId.Volume)
</script>
API reference
Module. A bundled options object, and useMirrorSlider(id) as
the programmatic API. Every part takes id to resolve a slider it is not nested
inside.
Slider.Root
Renders a <div role="group"> plus one visually hidden <input type="range">
per thumb.
Props
Options
Emits
Slot props
The field state set, plus the three below.
Slider.Control
Renders a <div> and owns the pointer interaction: a pointerdown anywhere on it
captures the pointer, moves the nearest thumb to it, focuses that thumb and
starts a drag. It sits apart from Slider.Track so the hit area can be larger
than the line.
Slot props
The field state set, plus dragging: boolean.
Slider.Track
Renders a <div>, the box the indicator and the thumbs position themselves
against and the one the pointer maths measures.
Slot props
The field state set.
Slider.Indicator
Renders a <div> sized to the filled portion, which runs from options.min to
the value for a single thumb, and between the lowest and highest thumb for a
range.
Slot props
The field state set.
Slider.Thumb
Renders a <span role="slider">.
Props
Slot props
The field state set, plus the five below.
Slider.Value
Renders an <output aria-live="off"> whose for names every thumb it reports.
Props
Slot props
The field state set, plus the four below.
Composable
useMirrorSlider(id) reaches a slider from anywhere in the app.
Data attributes
CSS variables
Errors
Accessibility
The root is a role="group". Each thumb is a role="slider" with
aria-valuemin, aria-valuemax, aria-valuenow, aria-orientation and,
while read-only, aria-readonly, and each is its own tab stop rather than part
of a roving-focus group.
aria-valuetext says which end of a range each thumb holds, and carries the
formatted value wherever options.format is set. options.getAriaValueText
replaces it, and options.getAriaLabel names the thumbs where the field label
is not enough.
Every key above is live in both orientations, and releasing one that moved the
value emits valueCommit. A range thumb stops at its neighbour on the keyboard
whatever options.thumbCollisionBehavior says, since a key press has no direction to
carry a neighbour in.