Range Slider
range-slider ui Two-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 Named registry:
npx shadcn-vue@latest add @uipkge/range-slider Installs to: app/components/ui/range-slider/ Examples
Loading interactive previews…
Loading interactive previews…
npm dependencies
Files installed (2)
-
app/components/ui/range-slider/RangeSlider.vue 8 kB
<script setup lang="ts"> import type { SliderRootProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed, ref, useId, watch } from 'vue' import { reactiveOmit } from '@vueuse/core' import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardProps } 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 /** Invert the slider */ inverted?: boolean /** Format thumb label */ thumbLabelFormat?: (value: number) => string } const props = withDefaults(defineProps<RangeSliderProps>(), { min: 0, max: 100, step: 1, 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', ) // Props only — value updates go through onValueUpdate so side displays stay in sync // without double-emitting via useForwardPropsEmits. const forwarded = useForwardProps(delegatedProps) const fieldId = useId() // Track displayed values for controlled + uncontrolled (defaultValue) modes. const displayValue = ref<[number, number]>(props.modelValue ?? props.defaultValue ?? [props.min, props.max]) watch( () => props.modelValue, (v) => { if (v) displayValue.value = v }, ) function onValueUpdate(val: number[] | undefined) { if (!val || val.length < 2) return const tuple = [val[0], val[1]] as [number, number] displayValue.value = tuple emits('update:modelValue', tuple) } // Color classes const colorClasses: Record<string, string> = { primary: 'bg-primary', secondary: 'bg-secondary', success: 'bg-success', warning: 'bg-warning', error: 'bg-destructive', info: 'bg-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 }) </script> <template> <div class="flex flex-col gap-2"> <label v-if="label" :for="fieldId" class="text-sm font-medium"> {{ label }} </label> <p v-if="hint && !hasError" :id="`${fieldId}-hint`" 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 tabular-nums" aria-hidden="true"> {{ displayValue[0] }} </div> <SliderRoot v-slot="{ modelValue }" v-bind="{ 'data-slot': 'range-slider', ...forwarded }" :id="fieldId" class="relative flex w-full touch-none items-center select-none" :aria-describedby="hint && !hasError ? `${fieldId}-hint` : hasError ? `${fieldId}-error` : undefined" :aria-invalid="hasError || undefined" @update:model-value="onValueUpdate" > <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" aria-hidden="true" > <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" :aria-label="label ? `${label} minimum` : 'Minimum value'" :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" :aria-label="label ? `${label} maximum` : 'Maximum value'" :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 tabular-nums" aria-hidden="true"> {{ displayValue[1] }} </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" :id="`${fieldId}-error`" class="flex flex-col gap-0.5" role="alert"> <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.2 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 /** Invert the slider */ inverted?: boolean /** Format thumb label */ thumbLabelFormat?: (value: number) => string }
Raw manifest: https://uipkge.dev/r/vue/range-slider.json