Range Slider
Vue formTwo-thumb range slider for "between X and Y" inputs — price filters, age ranges, time windows. Built on reka-ui with proper keyboard handling and aria-valuetext.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/range-slider.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/range-slider.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/range-slider.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/range-slider.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/range-slider
Examples
Dependencies
Files (2)
-
app/components/ui/range-slider/RangeSlider.vue 7.6 kB
<script setup lang="ts"> import type { SliderRootProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed } from 'vue' import { reactiveOmit } from '@vueuse/core' import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui' import { cn } from '@/lib/utils' // CLAUDE.md mandate: avoid `interface extends ReakUiX` (SFC compiler bails // in Vue 3.5+ because reka-ui has no exports.types). Intersection form below. export type RangeSliderProps = Omit<SliderRootProps, 'defaultValue'> & { class?: HTMLAttributes['class'] /** The controlled value of the range slider. Can be binded with v-model. */ modelValue?: [number, number] /** The value of the range slider that should be checked when initially rendered. */ defaultValue?: [number, number] /** When `true`, prevents the user from interacting with the range slider */ disabled?: boolean /** Minimum value */ min?: number /** Maximum value */ max?: number /** Step value */ step?: number /** Label for the range slider */ label?: string /** Hint text for the range slider */ hint?: string /** Error messages to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Custom color for the track fill */ color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string /** Thumb size */ thumbSize?: 'sm' | 'md' | 'lg' /** Track height */ trackHeight?: 'sm' | 'md' | 'lg' /** Show ticks */ showTicks?: boolean /** Tick interval */ tickInterval?: number /** Show thumb labels */ thumbLabel?: boolean /** Always show dirty state */ alwaysDirty?: boolean /** Invert the slider */ inverted?: boolean /** Density of the slider */ density?: 'compact' | 'default' | 'comfortable' /** Hide thumb labels (deprecated, use thumbLabel instead) */ hideThumbLabel?: boolean /** Format thumb label */ thumbLabelFormat?: (value: number) => string /** Strict range (thumbs cannot cross) */ strict?: boolean } const props = withDefaults(defineProps<RangeSliderProps>(), { min: 0, max: 100, step: 1, density: 'default', thumbSize: 'md', trackHeight: 'md', }) const emits = defineEmits<{ 'update:modelValue': [value: [number, number]] }>() const delegatedProps = reactiveOmit( props, 'class', 'label', 'hint', 'errorMessages', 'error', 'color', 'thumbSize', 'trackHeight', 'showTicks', 'tickInterval', 'thumbLabel', 'thumbLabelFormat', 'alwaysDirty', 'inverted', 'density', 'hideThumbLabel', 'strict', ) const forwarded = useForwardPropsEmits(delegatedProps, emits) // Color classes const colorClasses: Record<string, string> = { primary: 'bg-primary', secondary: 'bg-secondary', success: 'bg-[var(--success)]', warning: 'bg-[var(--warning)]', error: 'bg-destructive', info: 'bg-[var(--info)]', } // Track height classes const trackHeightClasses = { sm: 'h-1', md: 'h-1.5', lg: 'h-2', } // Thumb size classes const thumbSizeClasses = { sm: 'size-3', md: 'size-4', lg: 'size-5', } // Generate ticks const ticks = computed(() => { if (!props.showTicks || !props.tickInterval) return [] const result: number[] = [] for (let i = props.min; i <= props.max; i += props.tickInterval) { result.push(i) } return result }) // Build error state const hasError = computed(() => { if (props.error) return true if ( props.errorMessages && (typeof props.errorMessages === 'string' ? props.errorMessages : props.errorMessages.length > 0) ) return true return false }) // Calculate positions for ticks and labels const tickPositions = computed(() => { return ticks.value.map((tick) => ({ value: tick, position: ((tick - props.min) / (props.max - props.min)) * 100, })) }) </script> <template> <div class="flex flex-col gap-2"> <label v-if="label" class="text-sm font-medium"> {{ label }} </label> <p v-if="hint && !hasError" class="text-muted-foreground text-xs"> {{ hint }} </p> <div class="flex items-center gap-4"> <!-- Min value display --> <div class="text-muted-foreground min-w-[3rem] text-sm"> {{ modelValue?.[0] ?? min }} </div> <SliderRoot v-slot="{ modelValue }" v-bind="{ 'data-slot': 'range-slider', ...forwarded }" class="relative flex w-full touch-none items-center select-none" @update:model-value="(val) => emits('update:modelValue', val as [number, number])" > <SliderTrack data-uipkge data-slot="slider-track" :class="cn('bg-muted relative w-full overflow-hidden rounded-full', trackHeightClasses[trackHeight])" > <SliderRange data-uipkge data-slot="slider-range" :class="cn('absolute h-full', colorClasses[color as string] || colorClasses.primary)" /> </SliderTrack> <!-- Ticks --> <div v-if="showTicks && ticks.length > 0" class="pointer-events-none absolute top-1/2 right-0 left-0 flex -translate-y-1/2 justify-between" > <div v-for="tick in ticks" :key="tick" class="bg-muted-foreground/30 h-2 w-0.5 rounded-full" /> </div> <!-- Start Thumb (Min) --> <SliderThumb data-uipkge data-slot="slider-thumb" :index="0" :class=" cn( 'border-primary ring-ring/50 bg-background block rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', thumbSizeClasses[thumbSize], hasError && 'border-destructive', ) " > <span v-if="thumbLabel" class="bg-background absolute -top-6 left-1/2 -translate-x-1/2 rounded px-1 text-xs whitespace-nowrap" > {{ thumbLabelFormat ? thumbLabelFormat(modelValue?.[0] ?? 0) : (modelValue?.[0] ?? 0) }} </span> </SliderThumb> <!-- End Thumb (Max) --> <SliderThumb data-uipkge data-slot="slider-thumb" :index="1" :class=" cn( 'border-primary ring-ring/50 bg-background block rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', thumbSizeClasses[thumbSize], hasError && 'border-destructive', ) " > <span v-if="thumbLabel" class="bg-background absolute -top-6 left-1/2 -translate-x-1/2 rounded px-1 text-xs whitespace-nowrap" > {{ thumbLabelFormat ? thumbLabelFormat(modelValue?.[1] ?? 0) : (modelValue?.[1] ?? 0) }} </span> </SliderThumb> </SliderRoot> <!-- Max value display --> <div class="text-muted-foreground min-w-[3rem] text-sm"> {{ modelValue?.[1] ?? max }} </div> </div> <!-- Tick labels --> <div v-if="showTicks && ticks.length > 0" class="text-muted-foreground flex justify-between px-1 text-xs"> <span>{{ min }}</span> <span>{{ max }}</span> </div> <div v-if="hasError" class="flex flex-col gap-0.5"> <p v-if="typeof errorMessages === 'string'" class="text-destructive text-xs"> {{ errorMessages }} </p> <template v-else> <p v-for="(msg, i) in errorMessages" :key="i" class="text-destructive text-xs"> {{ msg }} </p> </template> </div> </div> </template> -
app/components/ui/range-slider/index.ts 1.3 kB
export { default as RangeSlider } from './RangeSlider.vue' export interface RangeSliderProps { /** The controlled value of the range slider. Can be binded with v-model. */ modelValue?: [number, number] /** The value of the range slider that should be checked when initially rendered. */ defaultValue?: [number, number] /** When `true`, prevents the user from interacting with the range slider */ disabled?: boolean /** Minimum value */ min?: number /** Maximum value */ max?: number /** Step value */ step?: number /** Label for the range slider */ label?: string /** Hint text for the range slider */ hint?: string /** Error messages to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Custom color for the track fill */ color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string /** Thumb size */ thumbSize?: 'sm' | 'md' | 'lg' /** Track height */ trackHeight?: 'sm' | 'md' | 'lg' /** Show ticks */ showTicks?: boolean /** Tick interval */ tickInterval?: number /** Show thumb labels */ thumbLabel?: boolean /** Always show dirty state */ alwaysDirty?: boolean /** Invert the slider */ inverted?: boolean /** Density of the slider */ density?: 'compact' | 'default' | 'comfortable' }
Raw manifest: https://uipkge.dev/r/vue/range-slider.json