---
name: slider
title: Slider
url: https://ui.frontic.com/components/ui/slider.md
tier: ui
install: npx @frontic/ui add slider
files:
  - components/ui/slider/Slider.vue
  - components/ui/slider/SliderControl.vue
  - components/ui/slider/index.ts
registryDependencies:
  - field
  - use-field-control
dependencies:
  - "@internationalized/number"
  - "@vueuse/core"
  - reka-ui
---

# Slider

Picks a value or a range by dragging thumbs along a track, with a label, a legend and values formatted like prices; joins a Field.

## Install

```sh
npx @frontic/ui add slider
```

Built from: [Field](https://ui.frontic.com/components/ui/field.md), [Use Field Control](https://ui.frontic.com/composables/use-field-control.md)

Used by: [Filter](https://ui.frontic.com/components/ui/filter.md), [Filters](https://ui.frontic.com/components/sections/filters.md)

npm packages: `@internationalized/number`, `@vueuse/core`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { Slider } from '@/components/ui/slider'
</script>

<template>
  <!-- The men's jackets run from €52 to €149. -->
  <Slider
    :default-value="[60, 120]"
    :min="50"
    :max="150"
    :step="10"
    :format-options="{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }"
    locale="en-IE"
    label="Price"
    show-legend
    class="w-72"
  />
</template>
```

## API reference

### Slider

Lets the user pick a value or range by dragging a handle along a track.

`data-slot="slider"` · `Slider.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `number[]` |  | The value of the slider when initially rendered. Use when you do not need to control the state of the slider. |
| `modelValue` | `number[] \| null`: `null \| number[]` |  | The controlled value of the slider. Can be bind as `v-model`. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the slider. |
| `orientation` | `DataOrientation`: `'vertical' \| 'horizontal'` |  | The orientation of the slider. |
| `dir` | `Direction`: `'ltr' \| 'rtl'` |  | The reading direction of the combobox when applicable.<br><br>If omitted, inherits globally from `ConfigProvider` or assumes LTR (left-to-right) reading mode. |
| `inverted` | `boolean` | `false` | Whether the slider is visually inverted. |
| `min` | `number` | `0` | The minimum value for the range. |
| `max` | `number` | `100` | The maximum value for the range. |
| `step` | `number` | `1` | The stepping interval. |
| `minStepsBetweenThumbs` | `number` |  | The minimum permitted steps between multiple thumbs. |
| `thumbAlignment` | `ThumbAlignment`: `'contain' \| 'overflow'` | `'contain'` | The alignment of the slider thumb.<br>- `contain`: thumbs will be contained within the bounds of the track.<br>- `overflow`: thumbs will not be bound by the track. No extra offset will be added. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `name` | `string` |  | The name of the field. Submitted with its owning form as part of a name/value pair. |
| `required` | `boolean` | `false` | When `true`, indicates that the user must set the value before the owning form can be submitted. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` | `string` |  | Shown as the field label above the slider; inside a Field, that Field's own label names it instead. |
| `showLegend` | `boolean` | `false` | Shows the minimum and maximum values below the track. |
| `formatOptions` | `Intl.NumberFormatOptions` |  | How the legend shows a value and a screen reader reads a thumb's, e.g. `{ style: 'currency', currency: 'EUR' }`. |
| `locale` | `string` |  | BCP 47 locale the values are formatted in; the ConfigProvider's when left out. |
| `format` | `((value: number) => string)`: `(value: number): string` |  | Formats a value for the legend and a screen reader, e.g. a price kept in cents; wins over `formatOptions`. |
| `thumbLabels` | `string[]` |  | Names each thumb for screen readers, after the label; Reka's "Minimum" and "Maximum" for a range when left out. |
| `ariaLabel` | `string` |  | Names the thumbs when there is no `label` and no Field label, e.g. inside a filter that shows its own title. |
| `ariaLabelledby` | `string` |  | Ids of the elements that name the thumbs when there is no `label` and no Field label. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `payload: number[] \| undefined` | Event handler called when the slider value changes |
| `valueCommit` | `payload: number[]` | Event handler called when the value changes at the end of an interaction. Useful when you only need to capture a final value e.g. to update a backend service. |

## Styling

| Part | Recipe |
| --- | --- |
| `control` | [`slider-control`](https://ui.frontic.com/DESIGN.md#slider-control) |
| `track` | [`slider-track`](https://ui.frontic.com/DESIGN.md#slider-track) |
| `range` | [`slider-range`](https://ui.frontic.com/DESIGN.md#slider-range) |
| `thumb` | [`slider-thumb`](https://ui.frontic.com/DESIGN.md#slider-thumb) |
| `legend` | [`slider-legend`](https://ui.frontic.com/DESIGN.md#slider-legend) |
| `disabled` | [`disabled`](https://ui.frontic.com/DESIGN.md#disabled) |

States with a look of their own: hover, focus and disabled.

## Source

`components/ui/slider/Slider.vue`

```vue
<script lang="ts">
/** Lets the user pick a value or range by dragging a handle along a track. */
export default {}
</script>

<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { NumberFormatter } from '@internationalized/number'
import { reactiveOmit } from '@vueuse/core'
import { injectFieldRootContext, useForwardPropsEmits, useLocale } from 'reka-ui'
import { Field, FieldLabel } from '@library/components/ui/field'
import SliderControl from './SliderControl.vue'
import { toRef } from 'vue'

const props = withDefaults(
  defineProps<
    SliderRootProps & {
      class?: HTMLAttributes['class']
      /** Shown as the field label above the slider; inside a Field, that Field's own label names it instead. */
      label?: string
      /** Shows the minimum and maximum values below the track. */
      showLegend?: boolean
      /** How the legend shows a value and a screen reader reads a thumb's, e.g. `{ style: 'currency', currency: 'EUR' }`. */
      formatOptions?: Intl.NumberFormatOptions
      /** BCP 47 locale the values are formatted in; the ConfigProvider's when left out. */
      locale?: string
      /** Formats a value for the legend and a screen reader, e.g. a price kept in cents; wins over `formatOptions`. */
      format?: (value: number) => string
      /** Names each thumb for screen readers, after the label; Reka's "Minimum" and "Maximum" for a range when left out. */
      thumbLabels?: string[]
      /** Names the thumbs when there is no `label` and no Field label, e.g. inside a filter that shows its own title. */
      ariaLabel?: string
      /** Ids of the elements that name the thumbs when there is no `label` and no Field label. */
      ariaLabelledby?: string
    }
  >(),
  {
    min: 0,
    max: 100,
    step: 1,
  },
)
const locale = useLocale(toRef(() => props.locale))

const emits = defineEmits<SliderRootEmits>()

const delegatedProps = reactiveOmit(props, 'class', 'label', 'showLegend', 'format', 'formatOptions', 'locale', 'thumbLabels', 'ariaLabel', 'ariaLabelledby')

const forwarded = useForwardPropsEmits(delegatedProps, emits)

/** Inside a Field of the consumer's the slider joins that one; on its own it brings a Field for its label. */
const outer = injectFieldRootContext(null)

const format = (value: number) => props.format?.(value) ?? (props.formatOptions ? new NumberFormatter(locale.value, props.formatOptions).format(value) : String(value))
</script>

<template>
  <component :is="outer ? 'div' : Field" data-slot="slider" :class="props.class">
    <FieldLabel v-if="label && !outer" :native-label="false">
      {{ label }}
    </FieldLabel>

    <SliderControl v-bind="forwarded" :format="props.format || formatOptions ? format : undefined" :thumb-labels="thumbLabels" :aria-label="ariaLabel" :aria-labelledby="ariaLabelledby" />

    <div v-if="showLegend" data-slot="slider-legend" class="fui-slider-legend flex justify-between">
      <span>{{ format(min) }}</span>
      <span>{{ format(max) }}</span>
    </div>
  </component>
</template>
```

`components/ui/slider/SliderControl.vue`

```vue
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui'
import { useId, useTemplateRef } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { useFieldControl } from '@library/composables/use-field-control'

const props = defineProps<
  SliderRootProps & {
    /** Reads a thumb's value out for screen readers. */
    format?: (value: number) => string
    /** Names each thumb for screen readers, after the Field's label. */
    thumbLabels?: string[]
    /** Names the thumbs when the Field has no label. */
    ariaLabel?: string
    /** Names the thumbs by these ids when the Field has no label. */
    ariaLabelledby?: string
  }
>()
const emits = defineEmits<SliderRootEmits>()

const forwarded = useForwardPropsEmits(reactiveOmit(props, 'format', 'thumbLabels', 'ariaLabel', 'ariaLabelledby'), emits)

// Delete once Reka's Slider reads the Field itself; the thumbs take the aria.
const { joined, report, field } = useFieldControl({
  props,
  forwarded,
  root: useTemplateRef<{ $el: HTMLElement }>('sliderRoot'),
  element: (root) => root?.querySelector<HTMLElement>('[role=slider]'),
  aria: 'none',
})
const thumbId = useId()
// Reka's own names for a range's two thumbs.
const RANGE_LABELS = ['Minimum', 'Maximum']
// A range thumb is named by the Field's label and then its own: "Price Minimum".
function thumbAttrs(index: number, count: number, thumbValue: number | undefined) {
  const own = props.thumbLabels?.[index] ?? (count === 2 ? RANGE_LABELS[index] : undefined)
  const id = `${thumbId}-${index}`
  const by = field?.labelId.value ?? props.ariaLabelledby
  return {
    id,
    ...(by ? { 'aria-labelledby': own ? `${by} ${id}` : by, ...(own ? { 'aria-label': own } : {}) } : { 'aria-label': [props.ariaLabel, own].filter(Boolean).join(' ') || undefined }),
    ...(props.format && thumbValue !== undefined ? { 'aria-valuetext': props.format(thumbValue) } : {}),
    ...(field
      ? {
          'aria-describedby': field.describedBy.value,
          'aria-invalid': (field.invalid.value && !field.disabled.value) || undefined,
        }
      : {}),
  }
}
</script>

<template>
  <SliderRoot
    ref="sliderRoot"
    v-slot="{ modelValue }"
    data-slot="slider-root"
    :data-vertical="props.orientation === 'vertical' ? '' : undefined"
    class="fui-slider-control relative touch-none select-none data-disabled:fui-disabled data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col"
    v-bind="joined"
    @update:model-value="report"
  >
    <SliderTrack
      data-slot="slider-track"
      :data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
      :data-vertical="props.orientation === 'vertical' ? '' : undefined"
      class="fui-slider-track relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
    >
      <SliderRange
        data-slot="slider-range"
        :data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
        :data-vertical="props.orientation === 'vertical' ? '' : undefined"
        class="fui-slider-range absolute select-none data-horizontal:h-full data-vertical:w-full"
      />
    </SliderTrack>

    <SliderThumb
      v-for="(thumbValue, key) in modelValue"
      :key="key"
      data-slot="slider-thumb"
      :data-vertical="props.orientation === 'vertical' ? '' : undefined"
      v-bind="thumbAttrs(key, modelValue?.length ?? 0, thumbValue)"
      class="fui-slider-thumb relative block shrink-0 select-none outline-none transition-colors after:absolute after:-inset-1.5 data-disabled:pointer-events-none data-disabled:fui-disabled"
    />
  </SliderRoot>
</template>
```
