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.
'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
| Prop | Type | Default | Description |
|---|---|---|---|
markers | number[] | "steps" | —No default documented | Marks along the track. Pass values for custom positions, or |
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
| Prop | Type | Default | Description |
|---|---|---|---|
markers | number[] | "steps" | —No default documented | Marks along the track. Pass values for custom positions, or |
size | "standard" | "comfortable" | "standard" | Height of the rail and thumb. |