Skip to main content
Nexus Design System
GitHub

Slider

Slider sets a number, or a range with two thumbs, by dragging along a track or with the arrow keys. Use it for settings such as volume or brightness. Give each slider an aria-label or aria-labelledby.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderDefault() {
  return (
    <div className="nx:w-96">
      <Slider defaultValue={[50]} max={100} step={1} aria-label="Volume" />
    </div>
  );
}

Installation

npx nxds-cli@latest add slider

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import { Slider } from '@/components/nxds/components/slider/slider';

Examples

Comfortable

Set size="comfortable" for a taller rail and thumb.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderComfortable() {
  return (
    <div className="nx:w-96">
      <Slider
        size="comfortable"
        defaultValue={[60]}
        max={100}
        step={1}
        aria-label="Playback position"
      />
    </div>
  );
}

Control rows

Overlay a label and the current value on a comfortable slider.

Roundness2
Volume47%
QualityMedium
Roundness2
'use client';

import { useState } from 'react';

import { Slider, type SliderProps } from '@/components/nxds/components/slider/slider';
import { cn } from '@/components/nxds/lib/utils';

interface SliderControlRowProps extends Omit<
  SliderProps,
  'aria-label' | 'onValueChange' | 'size' | 'value'
> {
  formatValue?: (value: number) => string;
  label: string;
  valueLabel?: string;
}

function SliderControlRow({
  className,
  defaultValue,
  disabled,
  formatValue,
  label,
  max = 100,
  min = 0,
  step = 1,
  valueLabel,
  ...props
}: SliderControlRowProps) {
  const [sliderValue, setSliderValue] = useState(
    Array.isArray(defaultValue) ? defaultValue : [min]
  );
  const currentValue = sliderValue[0] ?? min;
  const displayValue =
    valueLabel ?? formatValue?.(currentValue) ?? String(currentValue);

  return (
    <div className="nx:relative nx:h-8 nx:w-full">
      <Slider
        {...props}
        disabled={disabled}
        max={max}
        min={min}
        onValueChange={setSliderValue}
        size="comfortable"
        step={step}
        value={sliderValue}
        aria-label={label}
        className={cn('nx:absolute nx:inset-0', className)}
      />
      <div
        className={cn(
          'nx:pointer-events-none nx:absolute nx:inset-0 nx:flex nx:items-center nx:justify-between nx:gap-4 nx:px-4 nx:typography-label-default nx:text-muted-foreground',
          disabled && 'nx:text-disabled-foreground'
        )}
      >
        <span className="nx:min-w-0 nx:truncate">{label}</span>
        <span className="nx:shrink-0 nx:text-right">{displayValue}</span>
      </div>
    </div>
  );
}

const qualityLabels = ['Off', 'Low', 'Medium', 'High', 'Ultra'];

function formatQualityValue(value: number) {
  return qualityLabels[value] ?? String(value);
}

export default function SliderControlRows() {
  return (
    <div className="nx:w-96">
      <div className="nx:grid nx:w-full nx:gap-6">
        <SliderControlRow
          formatValue={String}
          label="Roundness"
          defaultValue={[2]}
          max={4}
          markers="steps"
          step={1}
        />
        <SliderControlRow
          formatValue={(value) => `${value}%`}
          label="Volume"
          defaultValue={[47]}
          max={100}
          step={1}
        />
        <SliderControlRow
          formatValue={formatQualityValue}
          label="Quality"
          defaultValue={[2]}
          max={4}
          markers="steps"
          step={1}
        />
        <SliderControlRow
          disabled
          formatValue={String}
          label="Roundness"
          defaultValue={[2]}
          max={4}
          markers="steps"
          step={1}
        />
      </div>
    </div>
  );
}

Range

Pass two values to select a range between two thumbs.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderRange() {
  return (
    <div className="nx:w-96">
      <Slider
        defaultValue={[25, 75]}
        max={100}
        step={1}
        aria-label="Price range"
      />
    </div>
  );
}

Steps

Set step so the thumb snaps to fixed increments.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderSteps() {
  return (
    <div className="nx:w-96">
      <Slider defaultValue={[40]} max={100} step={10} aria-label="Brightness" />
    </div>
  );
}

Vertical

Set orientation="vertical" inside a parent with a set height.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderVertical() {
  return (
    <div className="nx:w-96">
      <div className="nx:flex nx:h-48 nx:justify-center">
        <Slider
          defaultValue={[50]}
          max={100}
          step={1}
          orientation="vertical"
          aria-label="Vertical volume"
        />
      </div>
    </div>
  );
}

Disabled

A disabled slider uses muted colors and ignores input.

'use client';

import { Slider } from '@/components/nxds/components/slider/slider';

export default function SliderDisabled() {
  return (
    <div className="nx:w-96">
      <Slider disabled defaultValue={[50]} max={100} aria-label="Volume" />
    </div>
  );
}

Props

Slider

Slider props
PropTypeDefaultDescription
markersnumber[] | "steps"No default documented

Marks along the track. Pass values for custom positions, or "steps" for one mark per step.

size"standard" | "comfortable""standard"

Height of the rail and thumb.

Slider sets a number, or a range with two thumbs, by dragging along a track or with the arrow keys. Use it for settings such as volume or brightness. Give each slider an aria-label or aria-labelledby.

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

<template>
  <Slider :default-value="[50]" :max="100" :step="1" aria-label="Volume" />
</template>

Installation

  1. Install the following dependencies:

    npm install @vueuse/core@^14.1.0 class-variance-authority@^0.7.1 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/slider/Slider.vue
    <script setup lang="ts">
    import { reactiveOmit, useVModel } from '@vueuse/core';
    import {
      SliderRange,
      SliderRoot,
      type SliderRootEmits,
      type SliderRootProps,
      SliderThumb,
      SliderTrack,
      useForwardProps,
    } from 'reka-ui';
    import { computed, type HTMLAttributes, useAttrs } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import {
      sliderMarkerClassName,
      sliderRangeVariants,
      sliderThumbVariants,
      type SliderTrackVariants,
      sliderTrackVariants,
      sliderVariants,
    } from '.';
    
    const sliderFloatTolerance = 0.000001;
    
    function getStepMarkerValues(min: number, max: number, step: number) {
      if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
      if (!Number.isFinite(step) || step <= 0) return [];
      if (max <= min) return [];
    
      return Array.from(
        { length: Math.floor((max - min) / step) },
        (_, index) => min + step * (index + 1)
      );
    }
    
    function isMarkerCovered(marker: number, sortedValues: number[]) {
      const firstValue = sortedValues[0];
    
      if (firstValue === undefined) return false;
    
      if (sortedValues.length === 1) {
        return marker <= firstValue + sliderFloatTolerance;
      }
    
      const lastValue = sortedValues[sortedValues.length - 1];
    
      if (lastValue === undefined) return false;
    
      return (
        marker >= firstValue - sliderFloatTolerance &&
        marker <= lastValue + sliderFloatTolerance
      );
    }
    
    function isRangeBoundary(value: number | undefined, boundary: number) {
      return (
        value !== undefined &&
        Number.isFinite(value) &&
        Math.abs(value - boundary) <= sliderFloatTolerance
      );
    }
    
    type SliderSize = NonNullable<SliderTrackVariants['size']>;
    
    export interface SliderProps extends SliderRootProps {
      /**
       * Height of the rail and thumb.
       */
      size?: SliderSize;
    
      /**
       * Marks along the track. Pass values for custom positions, or `"steps"` for one
       * mark per step.
       */
      markers?: number[] | 'steps';
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<SliderProps>(), {
      size: 'standard',
      min: 0,
      max: 100,
      step: 1,
      orientation: 'horizontal',
    });
    const emits = defineEmits<SliderRootEmits>();
    
    const model = useVModel(props, 'modelValue', emits, {
      passive: true,
      defaultValue: props.defaultValue ?? [props.min],
    });
    const values = computed(() => model.value ?? [props.min]);
    
    const sortedValues = computed(() =>
      [...values.value]
        .filter((value) => Number.isFinite(value))
        .sort((a, b) => a - b)
    );
    const markerPositions = computed(() => {
      const valueRange = props.max - props.min;
      if (valueRange <= 0) return [];
      const markerValues =
        props.markers === 'steps'
          ? getStepMarkerValues(props.min, props.max, props.step)
          : (props.markers ?? []);
      return markerValues
        .filter(
          (marker) =>
            Number.isFinite(marker) &&
            marker > props.min &&
            marker < props.max &&
            !isMarkerCovered(marker, sortedValues.value)
        )
        .map((marker) => ((marker - props.min) / valueRange) * 100);
    });
    const rangeStartsAtMin = computed(
      () =>
        sortedValues.value.length <= 1 ||
        isRangeBoundary(sortedValues.value[0], props.min)
    );
    const rangeEndsAtMax = computed(() =>
      isRangeBoundary(sortedValues.value[sortedValues.value.length - 1], props.max)
    );
    
    // Reka puts role="slider" on each thumb, not the role-less root, so the
    // thumb-owned ARIA (naming and invalid state) goes there.
    const THUMB_ATTRS = ['aria-label', 'aria-labelledby', 'aria-invalid'];
    const attrs = useAttrs();
    
    function thumbAttrs() {
      return Object.fromEntries(THUMB_ATTRS.map((name) => [name, attrs[name]]));
    }
    
    function rootAttrs() {
      return Object.fromEntries(
        Object.entries(attrs).filter(([name]) => !THUMB_ATTRS.includes(name))
      );
    }
    
    const delegated = reactiveOmit(
      props,
      'class',
      'size',
      'markers',
      'modelValue',
      'defaultValue'
    );
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <SliderRoot
        v-bind="{ ...forwarded, ...rootAttrs() }"
        :model-value="values"
        data-slot="slider"
        :data-size="size"
        :class="cn(sliderVariants, props.class)"
        @update:model-value="model = $event"
        @value-commit="emits('valueCommit', $event)"
      >
        <SliderTrack
          data-slot="slider-track"
          :data-size="size"
          :class="sliderTrackVariants({ size })"
        >
          <SliderRange
            data-slot="slider-range"
            :data-orientation="orientation"
            :data-range-end="rangeEndsAtMax ? 'true' : undefined"
            :data-range-start="rangeStartsAtMin ? 'true' : undefined"
            :data-size="size"
            :class="
              cn(
                sliderRangeVariants,
                rangeStartsAtMin &&
                  (orientation === 'vertical'
                    ? 'nx:rounded-b-md'
                    : 'nx:rounded-l-md'),
                rangeEndsAtMax &&
                  (orientation === 'vertical'
                    ? 'nx:rounded-t-md'
                    : 'nx:rounded-r-md')
              )
            "
          />
          <span
            v-for="(position, index) in markerPositions"
            :key="`${position}-${index}`"
            aria-hidden="true"
            :class="sliderMarkerClassName"
            :data-disabled="disabled ? '' : undefined"
            :data-orientation="orientation"
            :data-size="size"
            data-slot="slider-marker"
            :style="
              orientation === 'vertical'
                ? { bottom: `${position}%` }
                : { left: `${position}%` }
            "
          />
        </SliderTrack>
        <SliderThumb
          v-for="(_, index) in values"
          :key="index"
          v-bind="thumbAttrs()"
          data-slot="slider-thumb"
          :data-orientation="orientation"
          :data-size="size"
          :class="sliderThumbVariants({ orientation })"
        />
      </SliderRoot>
    </template>
    
    components/slider/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Slider, type SliderProps } from './Slider.vue';
    
    export const sliderVariants =
      'nx:group/slider nx:relative nx:flex nx:w-full nx:touch-none nx:select-none nx:data-disabled:pointer-events-none nx:data-[orientation=horizontal]:h-8 nx:data-[orientation=horizontal]:items-center nx:data-[orientation=vertical]:h-full nx:data-[orientation=vertical]:min-h-44 nx:data-[orientation=vertical]:w-8 nx:data-[orientation=vertical]:flex-col nx:data-[orientation=vertical]:justify-center';
    
    export const sliderTrackVariants = cva(
      'nx:relative nx:grow nx:overflow-hidden nx:rounded-md nx:border-default nx:border-border-default nx:bg-muted nx:shadow-xs nx:transition-colors nx:duration-fast nx:data-disabled:border-border-disabled nx:data-disabled:bg-disabled',
      {
        variants: {
          size: {
            standard:
              'nx:p-0.5 nx:data-[orientation=horizontal]:h-4 nx:data-[orientation=horizontal]:w-full nx:data-[orientation=vertical]:h-full nx:data-[orientation=vertical]:w-4',
            comfortable:
              'nx:p-0.5 nx:data-[orientation=horizontal]:h-8 nx:data-[orientation=horizontal]:w-full nx:data-[orientation=vertical]:h-full nx:data-[orientation=vertical]:w-8',
          },
        },
        defaultVariants: {
          size: 'standard',
        },
      }
    );
    
    export const sliderRangeVariants =
      'nx:absolute nx:bg-control-background nx:bg-clip-content nx:p-0.5 nx:transition-colors nx:duration-fast nx:data-disabled:bg-disabled nx:data-[orientation=horizontal]:top-0 nx:data-[orientation=horizontal]:h-full nx:data-[orientation=vertical]:left-0 nx:data-[orientation=vertical]:w-full';
    
    export const sliderThumbVariants = cva(
      "nx:relative nx:block nx:size-5 nx:shrink-0 nx:bg-transparent nx:before:absolute nx:before:top-1/2 nx:before:left-1/2 nx:before:-translate-x-1/2 nx:before:-translate-y-1/2 nx:before:rounded-sm nx:before:bg-foreground nx:before:content-[''] nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:aria-invalid:focus-visible:outline-focus-error nx:data-disabled:before:bg-disabled-foreground",
      {
        variants: {
          orientation: {
            horizontal: 'nx:before:h-5 nx:before:w-0.5',
            vertical: 'nx:before:h-0.5 nx:before:w-5',
          },
        },
        defaultVariants: {
          orientation: 'horizontal',
        },
      }
    );
    
    export const sliderMarkerClassName =
      'nx:pointer-events-none nx:absolute nx:z-10 nx:size-1.5 nx:rounded-full nx:bg-muted-foreground-subtle nx:opacity-50 nx:transition-colors nx:duration-fast nx:data-disabled:bg-disabled-foreground nx:data-[orientation=horizontal]:top-1/2 nx:data-[orientation=horizontal]:-translate-x-1/2 nx:data-[orientation=horizontal]:-translate-y-1/2 nx:data-[orientation=vertical]:left-1/2 nx:data-[orientation=vertical]:-translate-x-1/2 nx:data-[orientation=vertical]:translate-y-1/2';
    
    export type SliderTrackVariants = VariantProps<typeof sliderTrackVariants>;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Slider } from '@/components/slider';

Examples

Comfortable

Set size="comfortable" for a taller rail and thumb.

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

<template>
  <Slider
    size="comfortable"
    :default-value="[60]"
    :max="100"
    :step="1"
    aria-label="Playback position"
  />
</template>

Control rows

Overlay a label and the current value on a comfortable slider.

<script setup lang="ts">
import { ref } from 'vue';

import { Slider } from '@/components/slider';

const roundness = ref([2]);
const volume = ref([47]);
const quality = ref([2]);
const qualityLabels = ['Off', 'Low', 'Medium', 'High', 'Ultra'];
const locked = ref([2]);
</script>

<template>
  <div class="nx:grid nx:w-full nx:gap-6">
    <div class="nx:relative nx:h-8 nx:w-full">
      <Slider
        v-model="roundness"
        :max="4"
        markers="steps"
        size="comfortable"
        :step="1"
        aria-label="Roundness"
        class="nx:absolute nx:inset-0"
      />
      <div
        class="nx:pointer-events-none nx:absolute nx:inset-0 nx:flex nx:items-center nx:justify-between nx:gap-4 nx:px-4 nx:typography-label-default nx:text-muted-foreground"
      >
        <span class="nx:min-w-0 nx:truncate">Roundness</span>
        <span class="nx:shrink-0 nx:text-right">{{ roundness[0] }}</span>
      </div>
    </div>
    <div class="nx:relative nx:h-8 nx:w-full">
      <Slider
        v-model="volume"
        :max="100"
        size="comfortable"
        :step="1"
        aria-label="Volume"
        class="nx:absolute nx:inset-0"
      />
      <div
        class="nx:pointer-events-none nx:absolute nx:inset-0 nx:flex nx:items-center nx:justify-between nx:gap-4 nx:px-4 nx:typography-label-default nx:text-muted-foreground"
      >
        <span class="nx:min-w-0 nx:truncate">Volume</span>
        <span class="nx:shrink-0 nx:text-right">{{ volume[0] }}%</span>
      </div>
    </div>
    <div class="nx:relative nx:h-8 nx:w-full">
      <Slider
        v-model="quality"
        :max="4"
        markers="steps"
        size="comfortable"
        :step="1"
        aria-label="Quality"
        class="nx:absolute nx:inset-0"
      />
      <div
        class="nx:pointer-events-none nx:absolute nx:inset-0 nx:flex nx:items-center nx:justify-between nx:gap-4 nx:px-4 nx:typography-label-default nx:text-muted-foreground"
      >
        <span class="nx:min-w-0 nx:truncate">Quality</span>
        <span class="nx:shrink-0 nx:text-right">{{
          qualityLabels[quality[0]]
        }}</span>
      </div>
    </div>
    <div class="nx:relative nx:h-8 nx:w-full">
      <Slider
        v-model="locked"
        disabled
        :max="4"
        markers="steps"
        size="comfortable"
        :step="1"
        aria-label="Roundness"
        class="nx:absolute nx:inset-0"
      />
      <div
        class="nx:pointer-events-none nx:absolute nx:inset-0 nx:flex nx:items-center nx:justify-between nx:gap-4 nx:px-4 nx:typography-label-default nx:text-disabled-foreground"
      >
        <span class="nx:min-w-0 nx:truncate">Roundness</span>
        <span class="nx:shrink-0 nx:text-right">{{ locked[0] }}</span>
      </div>
    </div>
  </div>
</template>

Range

Pass two values to select a range between two thumbs.

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

<template>
  <Slider
    :default-value="[25, 75]"
    :max="100"
    :step="1"
    aria-label="Price range"
  />
</template>

Steps

Set step so the thumb snaps to fixed increments.

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

<template>
  <Slider :default-value="[40]" :max="100" :step="10" aria-label="Brightness" />
</template>

Vertical

Set orientation="vertical" inside a parent with a set height.

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

<template>
  <div class="nx:flex nx:h-48 nx:justify-center">
    <Slider
      :default-value="[50]"
      :max="100"
      :step="1"
      orientation="vertical"
      aria-label="Vertical volume"
    />
  </div>
</template>

Disabled

A disabled slider uses muted colors and ignores input.

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

<template>
  <Slider disabled :default-value="[50]" :max="100" aria-label="Volume" />
</template>

Props

Slider

Slider props
PropTypeDefaultDescription
markersnumber[] | "steps"No default documented

Marks along the track. Pass values for custom positions, or "steps" for one mark per step.

size"standard" | "comfortable""standard"

Height of the rail and thumb.