UIPackage

Range Slider

Vue form
Edit on GitHub

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

$ npx 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