Skip to content

Slider

A track and one or more thumbs, for a number whose bounds are known in advance.

View source View as Markdown

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.

Volume60
App.vue
<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; a width alone 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 Label inside a Field or an aria-label on 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.

NameRequiredDescription
Slider.Root true Renders a <div role="group"> plus one visually hidden <input type="range"> per thumb.
Slider.ValueRenders 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.IndicatorSized 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

PropTypeDefault
id
stringgenerated
modelValue
number | number[] | undefinedundefined
defaultValue
number | number[]options.min
options
SliderOptionssee below

Options

OptionTypeDefault
min
number0
max
number100
step
number1
largeStep
numberstep * 10
minStepsBetweenThumbs
number0
thumbCollisionBehavior
'push' | 'swap' | 'none''push'
orientation
'horizontal' | 'vertical''horizontal'
dir
'ltr' | 'rtl'inherited
locale
Intl.LocalesArgumentthe runtime locale
format
Intl.NumberFormatOptionsundefined
getAriaLabel
(index: number) => stringundefined
getAriaValueText
(formatted: string, value: number, index: number) => stringundefined
name
stringinherited from Field
form
stringundefined
disabled
booleaninherited from Field
readOnly
booleaninherited from Field
required
booleaninherited from Field

Emits

EmitPayload
update:modelValue
number | number[]
valueCommit
number | number[]
dragging
boolean

Slot props

The field state set, plus the three below.

PropType
value
number | number[]
percentages
number[]
dragging
boolean

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

PropTypeDefault
id
stringinjected
elementId
stringgenerated
index
numberits document position
disabled
booleanfalse

Slot props

The field state set, plus the five below.

PropType
value
number
percentage
number
index
number
active
boolean
disabled
boolean

Slider.Value

Renders an <output aria-live="off"> whose for names every thumb it reports.

Props

PropTypeDefault
id
stringinjected

Slot props

The field state set, plus the four below.

PropType
value
number | number[]
values
number[]
formatted
string
formattedValues
string[]

Composable

useMirrorSlider(id) reaches a slider from anywhere in the app.

KeyType
value
ComputedRef<SliderModelValue>
values
ComputedRef<Array<number>>
percentages
ComputedRef<Array<number>>
dragging
ComputedRef<boolean>
activeIndex
ComputedRef<number | null>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
setValue
(next: SliderModelValue) => void
setValueAt
(index: number, next: number) => void
focusThumb
(index: number) => void

Data attributes

PartAttributeValue
all
data-disabled
true
all
data-readonly
true
all
data-required
true
all
data-valid
true
all
data-invalid
true
all
data-dirty
true
all
data-touched
true
all
data-filled
true
all
data-focused
true
all
data-orientation
horizontal | vertical
all
data-dragging
true
Slider.Thumb
data-active
true
Slider.Thumb
data-index
the index
Slider.Thumb
data-disabled
true

CSS variables

PartVariableDefault
Slider.Thumb
--mirror-slider-thumb-size
1rem
Slider.Track
--mirror-slider-track-size
0.25rem
Slider.Control
--mirror-slider-cursor
pointer
Slider.Control
--mirror-slider-dragging-cursor
grabbing
Slider.Control
--mirror-slider-disabled-cursor
not-allowed

Errors

Code
missing_slider_context
invalid_slider_range
invalid_thumb_index

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.

KeyBehaviour
ArrowRightIncrease by options.step. Decreases in an RTL horizontal slider.
ArrowLeftDecrease by options.step. Increases in an RTL horizontal slider.
ArrowUpIncrease by options.step. Never mirrors.
ArrowDownDecrease by options.step. Never mirrors.
Shift + any arrowIncrease or decrease by options.largeStep, mirroring the same way the plain arrow does.
PageUp / PageDownIncrease or decrease by options.largeStep.
Home / EndJump to options.min or options.max.

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.