Skip to content

Slider ​

Choose a number, a level, or the endpoints of a numeric range. Pass one number for a single handle, or two numbers for a range.

Preview Live · Vue
Usage
vue
<script setup>
import { ref } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
const rollout = ref(40)
</script>

<template>
  <label for="rollout">Release rollout</label>
  <output>{{ rollout }}%</output>
  <Slider
    id="rollout"
    v-model="rollout"
    name="rollout"
    :value-text="(value) => value + ' percent'"
  />
</template>
jsx
import { useState } from 'react'
import Slider from '@/components/ui/slider/Slider.jsx'

export default function ReleaseRollout() {
  const [rollout, setRollout] = useState(40)
  return (
    <>
      <label htmlFor="rollout">Release rollout</label>
      <output>{rollout}%</output>
      <Slider
        id="rollout"
        value={rollout}
        onChange={setRollout}
        name="rollout"
        valueText={(value) => value + ' percent'}
      />
    </>
  )
}
svelte
<script>
  import Slider from '@/components/ui/slider/Slider.svelte'
  let rollout = $state(40)
</script>

<label for="rollout">Release rollout</label>
<output>{rollout}%</output>
<Slider
  id="rollout"
  bind:value={rollout}
  name="rollout"
  valueText={(value) => value + ' percent'}
/>
View source Slider.vue
Slider.vue
vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
import {
  moveSlider,
  sliderBounds,
  sliderKey,
  sliderPercent,
  sliderValue
} from './slider.js'

defineOptions({ inheritAttrs: false })
const props = defineProps({
  modelValue: { type: [Number, Array], default: undefined },
  min: { type: Number, default: 0 },
  max: { type: Number, default: 100 },
  step: { type: [Number, String], default: 1 },
  minStepsBetween: { type: Number, default: 0 },
  bigStep: { type: Number, default: undefined },
  labels: { type: Array, default: () => ['Minimum value', 'Maximum value'] },
  marks: { type: Array, default: () => [] },
  valueText: { type: Function, default: undefined },
  disabled: Boolean,
  name: { type: [String, Array], default: undefined }
})
const emit = defineEmits(['update:modelValue', 'change', 'commit'])
const attrs = useAttrs()
const root = ref()
const inputs = ref([])
const bounds = computed(() =>
  sliderBounds(props.min, props.max, props.step, props.minStepsBetween)
)
const value = computed(() => sliderValue(props.modelValue, bounds.value))
const values = computed(() =>
  Array.isArray(value.value) ? value.value : [value.value]
)
const range = computed(() => values.value.length === 2)
const disabled = computed(
  () => props.disabled || bounds.value.min === bounds.value.max
)
const dragging = ref(false)
const thumbSize = ref(20)
const thumbMeasure = ref()
let geometryObserver
function measureThumb() {
  const width = thumbMeasure.value?.getBoundingClientRect().width
  if (Number.isFinite(width) && width > 0) thumbSize.value = width
}
const initialValue = Array.isArray(props.modelValue)
  ? [...props.modelValue]
  : props.modelValue
let gesture
let form
const lastIndex = ref(0)
const classes = computed(() =>
  twMerge(
    'relative block h-11 w-full touch-pan-y select-none text-gray-950 dark:text-white data-disabled:cursor-not-allowed data-disabled:opacity-40 [--thumb-size:1.25rem]',
    attrs.class
  )
)
const inputAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    dir: _dir,
    id: _id,
    'aria-label': _label,
    ...rest
  } = attrs
  if (range.value) delete rest['aria-labelledby']
  return rest
})
const fill = computed(() => ({
  insetInlineStart: `${range.value ? sliderPercent(values.value[0], bounds.value) : 0}%`,
  width: `${sliderPercent(values.value.at(-1), bounds.value) - (range.value ? sliderPercent(values.value[0], bounds.value) : 0)}%`
}))
const ticks = computed(() =>
  props.marks
    .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
    .filter(
      (mark) =>
        Number.isFinite(mark.value) &&
        mark.value >= bounds.value.min &&
        mark.value <= bounds.value.max
    )
)
const limits = (index) => ({
  min: index === 1 ? values.value[0] + bounds.value.gap : bounds.value.min,
  max:
    range.value && index === 0
      ? values.value[1] - bounds.value.gap
      : bounds.value.max
})
function publish(next) {
  emit('update:modelValue', next)
  emit('change', next)
  return next
}
function input(event, index) {
  const next = moveSlider(
    value.value,
    index,
    event.target.valueAsNumber,
    bounds.value
  )
  event.target.value = Array.isArray(next) ? next[index] : next
  publish(next)
  nextTick(() => {
    event.target.value = values.value[index]
  })
}
function rtl() {
  return getComputedStyle(root.value).direction === 'rtl'
}
function pointerValue(event) {
  measureThumb()
  const rect = root.value.getBoundingClientRect()
  const fraction = Math.max(
    0,
    Math.min(
      1,
      (event.clientX - rect.left - thumbSize.value / 2) /
        Math.max(1, rect.width - thumbSize.value)
    )
  )
  return (
    bounds.value.min +
    (rtl() ? 1 - fraction : fraction) * (bounds.value.max - bounds.value.min)
  )
}
function pointerDown(event) {
  if (
    disabled.value ||
    inputs.value[0]?.matches(':disabled') ||
    event.button !== 0 ||
    event.isPrimary === false
  )
    return
  event.preventDefault()
  const candidate = pointerValue(event)
  const distances = values.value.map((item) => Math.abs(item - candidate))
  const index = range.value
    ? distances[0] === distances[1]
      ? candidate < values.value[0]
        ? 0
        : candidate > values.value[1]
          ? 1
          : lastIndex.value === 0
            ? 1
            : 0
      : distances[0] < distances[1]
        ? 0
        : 1
    : 0
  lastIndex.value = index
  inputs.value[index]?.focus()
  gesture = {
    id: event.pointerId,
    index,
    start: value.value,
    next: value.value
  }
  root.value.setPointerCapture?.(event.pointerId)
  dragging.value = true
  pointerMove(event)
}
function pointerMove(event) {
  if (!gesture || gesture.id !== event.pointerId) return
  if (disabled.value || inputs.value[gesture.index]?.matches(':disabled')) {
    const id = gesture.id
    gesture = undefined
    dragging.value = false
    if (root.value.hasPointerCapture?.(id)) root.value.releasePointerCapture(id)
    return
  }
  gesture.next = moveSlider(
    value.value,
    gesture.index,
    pointerValue(event),
    bounds.value
  )
  publish(gesture.next)
}
function endPointer(event, cancelled = false) {
  if (!gesture || gesture.id !== event.pointerId) return
  const finished = gesture
  gesture = undefined
  dragging.value = false
  if (root.value.hasPointerCapture?.(event.pointerId))
    root.value.releasePointerCapture(event.pointerId)
  if (disabled.value || inputs.value[finished.index]?.matches(':disabled'))
    return
  if (cancelled) publish(finished.start)
  else emit('commit', finished.next)
}
function keydown(event, index) {
  if (event.defaultPrevented) return
  if (disabled.value || event.target.matches(':disabled')) return
  if (
    !event.shiftKey &&
    !['PageUp', 'PageDown', 'Home', 'End'].includes(event.key)
  )
    return
  const limit = limits(index)
  const increment =
    props.bigStep ??
    (bounds.value.step === 'any'
      ? (bounds.value.max - bounds.value.min) / 10
      : bounds.value.step * 10)
  const candidate = sliderKey(
    event.key,
    values.value[index],
    limit.min,
    limit.max,
    increment,
    rtl()
  )
  if (candidate === undefined) return
  event.preventDefault()
  const next = publish(moveSlider(value.value, index, candidate, bounds.value))
  emit('commit', next)
}
function reset(event) {
  queueMicrotask(() => {
    if (event.defaultPrevented) return
    publish(sliderValue(initialValue, bounds.value))
    nextTick(() =>
      inputs.value.forEach((input, index) => {
        if (input) input.value = values.value[index]
      })
    )
  })
}
function listenForm() {
  form?.removeEventListener('reset', reset)
  form = inputs.value[0]?.form
  form?.addEventListener('reset', reset)
}
onMounted(() => {
  listenForm()
  measureThumb()
  geometryObserver = new ResizeObserver(measureThumb)
  geometryObserver.observe(root.value)
  geometryObserver.observe(thumbMeasure.value)
})
watch(
  () => attrs.class,
  () => nextTick(measureThumb)
)
watch(() => attrs.form, listenForm, { flush: 'post' })
onBeforeUnmount(() => {
  form?.removeEventListener('reset', reset)
  geometryObserver?.disconnect()
})
defineExpose({ inputs })
</script>

<template>
  <span
    ref="root"
    data-slot="slider"
    :class="classes"
    :style="attrs.style"
    :dir="attrs.dir"
    :role="range ? 'group' : undefined"
    :aria-label="range ? attrs['aria-label'] : undefined"
    :aria-labelledby="range ? attrs['aria-labelledby'] : undefined"
    :data-disabled="disabled || undefined"
    :data-dragging="dragging || undefined"
    @pointerdown="pointerDown"
    @pointermove="pointerMove"
    @pointerup="endPointer"
    @pointercancel="endPointer($event, true)"
    @lostpointercapture="endPointer($event, true)"
  >
    <span
      aria-hidden="true"
      data-slot="slider-track"
      class="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700"
      :style="{
        insetInline: 'calc(var(--thumb-size) / 2)',
        transform: 'translateY(-50%)',
        translate: 'none'
      }"
    >
      <span
        data-slot="slider-fill"
        class="absolute h-full rounded-full bg-current"
        :style="fill"
      />
      <span
        v-for="mark in ticks"
        :key="mark.value"
        data-slot="slider-mark"
        class="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/65 dark:bg-gray-950/65 rtl:translate-x-1/2"
        :style="{
          insetInlineStart: `${sliderPercent(mark.value, bounds)}%`,
          transform: 'translate(calc(-50% * var(--slider-direction, 1)), -50%)',
          translate: 'none'
        }"
      />
    </span>
    <span
      ref="thumbMeasure"
      aria-hidden="true"
      class="pointer-events-none invisible absolute h-0"
      style="width: var(--thumb-size)"
    />
    <input
      v-for="(item, index) in values"
      :key="index"
      :ref="(el) => (inputs[index] = el)"
      v-bind="inputAttrs"
      type="range"
      data-slot="slider-thumb"
      :data-index="index"
      :id="index === 0 ? attrs.id : attrs.id ? `${attrs.id}-end` : undefined"
      :name="Array.isArray(name) ? name[index] : name"
      :disabled="disabled"
      :min="bounds.min"
      :max="bounds.max"
      :step="bounds.step"
      :value="item"
      :aria-label="
        range
          ? labels[index] || (index ? 'Maximum value' : 'Minimum value')
          : attrs['aria-label']
      "
      :aria-valuemin="limits(index).min"
      :aria-valuemax="limits(index).max"
      :aria-valuetext="valueText?.(item, index)"
      :style="{ zIndex: index === lastIndex ? 2 : 1 }"
      class="klean-slider-input absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed"
      @input="input($event, index)"
      @change="emit('commit', value)"
      @keydown="keydown($event, index)"
      @focus="lastIndex = index"
    />
    <span
      v-for="mark in ticks.filter((tick) => tick.label !== undefined)"
      :key="`label-${mark.value}`"
      data-slot="slider-mark-label"
      class="pointer-events-none absolute top-full -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 rtl:translate-x-1/2"
      :style="{
        insetInlineStart: `calc(${thumbSize / 2}px + (100% - ${thumbSize}px) * ${sliderPercent(mark.value, bounds) / 100})`
      }"
      >{{ mark.label }}</span
    >
  </span>
</template>

<style scoped>
@layer base {
  [data-slot='slider']:dir(rtl) {
    --slider-direction: -1;
  }
  .klean-slider-input {
    pointer-events: none;
  }
  .klean-slider-input::-webkit-slider-runnable-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-moz-range-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-webkit-slider-thumb {
    appearance: none;
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin-top: calc((6px - var(--thumb-size)) / 2);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input::-moz-range-thumb {
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input:focus-visible::-webkit-slider-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  .klean-slider-input:focus-visible::-moz-range-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  @media (forced-colors: active) {
    [data-slot='slider-track'] {
      background: GrayText;
    }
    [data-slot='slider-fill'] {
      background: Highlight;
    }
    .klean-slider-input::-webkit-slider-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
    .klean-slider-input::-moz-range-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
  }
}
</style>

When to use ​

Use Slider when relative adjustment is easier than typing: rollout percentages, volume, playback speed, price filters, or a small ordered set of levels. Show the current value beside its label.

For an exact amount, pair it with Input, bound to the same value. For actions such as deploying a release, use Slide, not Slider.

Installation ​

sh
npx klean-ui add slider

Single value ​

Copy the example above for your framework.

Numeric range ​

A two-number value selects a range. Give each endpoint a distinct label. The handles cannot cross, and their keyboard order stays minimum then maximum.

vue
<Slider
  v-model="budget"
  :max="1000"
  :step="10"
  aria-label="Monthly budget"
  :labels="['Minimum budget', 'Maximum budget']"
  :value-text="(value) => '$' + value"
  :name="['minimum', 'maximum']"
/>
jsx
<Slider
  value={budget}
  onChange={setBudget}
  max={1000}
  step={10}
  aria-label="Monthly budget"
  labels={['Minimum budget', 'Maximum budget']}
  valueText={(value) => '$' + value}
  name={['minimum', 'maximum']}
/>
svelte
<Slider
  bind:value={budget}
  max={1000}
  step={10}
  aria-label="Monthly budget"
  labels={['Minimum budget', 'Maximum budget']}
  valueText={(value) => '$' + value}
  name={['minimum', 'maximum']}
/>

Initialize budget to [200, 800]. Use minStepsBetween to keep a minimum distance, measured in steps. With a step of 10 and a minimum separation of two steps, the handles stay at least 20 apart. Without a minimum distance, both endpoints may select the same value.

A single name submits both endpoints under that name; read them with FormData.getAll(). A two-name array submits each endpoint separately.

Steps and marks ​

Set min, max, and step to describe the values your application accepts. Steps begin at min. Decimal and negative values are supported; step="any" allows continuous adjustment.

Marks identify meaningful positions. Pass numbers for dots, or { value, label } objects for dots with labels. Marks do not restrict the values: step does.

vue
<Slider
  v-model="effort"
  :min="1"
  :max="5"
  aria-label="Thinking effort"
  :marks="[
    { value: 1, label: 'Quick' },
    { value: 3, label: 'Balanced' },
    { value: 5, label: 'Deep' }
  ]"
  :value-text="
    (value) => ['', 'Quick', 'Light', 'Balanced', 'Thorough', 'Deep'][value]
  "
/>
jsx
<Slider
  value={effort}
  onChange={setEffort}
  min={1}
  max={5}
  aria-label="Thinking effort"
  marks={[
    { value: 1, label: 'Quick' },
    { value: 3, label: 'Balanced' },
    { value: 5, label: 'Deep' }
  ]}
  valueText={(value) =>
    ['', 'Quick', 'Light', 'Balanced', 'Thorough', 'Deep'][value]
  }
/>
svelte
<Slider
  bind:value={effort}
  min={1}
  max={5}
  aria-label="Thinking effort"
  marks={[
    { value: 1, label: 'Quick' },
    { value: 3, label: 'Balanced' },
    { value: 5, label: 'Deep' }
  ]}
  valueText={(value) =>
    ['', 'Quick', 'Light', 'Balanced', 'Thorough', 'Deep'][value]}
/>

Keep marks sparse enough that their labels fit at narrow widths. Choose a step that lands exactly on your maximum when that endpoint matters.

Changes and commits ​

Binding updates the value while the user adjusts it. Use commit for work that should wait until an adjustment completes, such as submitting an expensive filter request.

PurposeVueReactSvelte
Bind valuev-modelvalue + onChangebind:value
Observe changes@changeonChangeonchange
Completed adjustment@commitonCommitoncommit
ClassesclassclassNameclass

Callbacks receive the number or two-number array, not a DOM event. An interrupted pointer gesture restores its starting value without committing. Form reset restores the initial value.

Styling ​

Use ordinary Tailwind classes. For example, class="text-blue-600" changes the filled track and keyboard focus color. Caller classes win over the neutral defaults.

The root exposes data-slot="slider", data-disabled, and data-dragging. Descendants expose slider-track, slider-fill, slider-thumb, slider-mark, and slider-mark-label. Use ordinary descendant selectors for track, fill, and mark styling; the native thumb accepts browser thumb pseudo-element selectors. Extract an application component when a visual treatment repeats.

Keep value movement immediate. Do not add an animation that makes the handle lag behind dragging or keyboard input.

Large stepped control ​

A thick track, larger handle, dots, and a changing label are all caller styling and ordinary surrounding markup. This example uses the same Slider with five steps. At Ultra, the label and icon turn violet and the filled track becomes a blue-to-violet gradient. Its reset button returns to Medium and restores the blue styling.

Preview Live · Vue
View source Slider.vue
Slider.vue
vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
import {
  moveSlider,
  sliderBounds,
  sliderKey,
  sliderPercent,
  sliderValue
} from './slider.js'

defineOptions({ inheritAttrs: false })
const props = defineProps({
  modelValue: { type: [Number, Array], default: undefined },
  min: { type: Number, default: 0 },
  max: { type: Number, default: 100 },
  step: { type: [Number, String], default: 1 },
  minStepsBetween: { type: Number, default: 0 },
  bigStep: { type: Number, default: undefined },
  labels: { type: Array, default: () => ['Minimum value', 'Maximum value'] },
  marks: { type: Array, default: () => [] },
  valueText: { type: Function, default: undefined },
  disabled: Boolean,
  name: { type: [String, Array], default: undefined }
})
const emit = defineEmits(['update:modelValue', 'change', 'commit'])
const attrs = useAttrs()
const root = ref()
const inputs = ref([])
const bounds = computed(() =>
  sliderBounds(props.min, props.max, props.step, props.minStepsBetween)
)
const value = computed(() => sliderValue(props.modelValue, bounds.value))
const values = computed(() =>
  Array.isArray(value.value) ? value.value : [value.value]
)
const range = computed(() => values.value.length === 2)
const disabled = computed(
  () => props.disabled || bounds.value.min === bounds.value.max
)
const dragging = ref(false)
const thumbSize = ref(20)
const thumbMeasure = ref()
let geometryObserver
function measureThumb() {
  const width = thumbMeasure.value?.getBoundingClientRect().width
  if (Number.isFinite(width) && width > 0) thumbSize.value = width
}
const initialValue = Array.isArray(props.modelValue)
  ? [...props.modelValue]
  : props.modelValue
let gesture
let form
const lastIndex = ref(0)
const classes = computed(() =>
  twMerge(
    'relative block h-11 w-full touch-pan-y select-none text-gray-950 dark:text-white data-disabled:cursor-not-allowed data-disabled:opacity-40 [--thumb-size:1.25rem]',
    attrs.class
  )
)
const inputAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    dir: _dir,
    id: _id,
    'aria-label': _label,
    ...rest
  } = attrs
  if (range.value) delete rest['aria-labelledby']
  return rest
})
const fill = computed(() => ({
  insetInlineStart: `${range.value ? sliderPercent(values.value[0], bounds.value) : 0}%`,
  width: `${sliderPercent(values.value.at(-1), bounds.value) - (range.value ? sliderPercent(values.value[0], bounds.value) : 0)}%`
}))
const ticks = computed(() =>
  props.marks
    .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
    .filter(
      (mark) =>
        Number.isFinite(mark.value) &&
        mark.value >= bounds.value.min &&
        mark.value <= bounds.value.max
    )
)
const limits = (index) => ({
  min: index === 1 ? values.value[0] + bounds.value.gap : bounds.value.min,
  max:
    range.value && index === 0
      ? values.value[1] - bounds.value.gap
      : bounds.value.max
})
function publish(next) {
  emit('update:modelValue', next)
  emit('change', next)
  return next
}
function input(event, index) {
  const next = moveSlider(
    value.value,
    index,
    event.target.valueAsNumber,
    bounds.value
  )
  event.target.value = Array.isArray(next) ? next[index] : next
  publish(next)
  nextTick(() => {
    event.target.value = values.value[index]
  })
}
function rtl() {
  return getComputedStyle(root.value).direction === 'rtl'
}
function pointerValue(event) {
  measureThumb()
  const rect = root.value.getBoundingClientRect()
  const fraction = Math.max(
    0,
    Math.min(
      1,
      (event.clientX - rect.left - thumbSize.value / 2) /
        Math.max(1, rect.width - thumbSize.value)
    )
  )
  return (
    bounds.value.min +
    (rtl() ? 1 - fraction : fraction) * (bounds.value.max - bounds.value.min)
  )
}
function pointerDown(event) {
  if (
    disabled.value ||
    inputs.value[0]?.matches(':disabled') ||
    event.button !== 0 ||
    event.isPrimary === false
  )
    return
  event.preventDefault()
  const candidate = pointerValue(event)
  const distances = values.value.map((item) => Math.abs(item - candidate))
  const index = range.value
    ? distances[0] === distances[1]
      ? candidate < values.value[0]
        ? 0
        : candidate > values.value[1]
          ? 1
          : lastIndex.value === 0
            ? 1
            : 0
      : distances[0] < distances[1]
        ? 0
        : 1
    : 0
  lastIndex.value = index
  inputs.value[index]?.focus()
  gesture = {
    id: event.pointerId,
    index,
    start: value.value,
    next: value.value
  }
  root.value.setPointerCapture?.(event.pointerId)
  dragging.value = true
  pointerMove(event)
}
function pointerMove(event) {
  if (!gesture || gesture.id !== event.pointerId) return
  if (disabled.value || inputs.value[gesture.index]?.matches(':disabled')) {
    const id = gesture.id
    gesture = undefined
    dragging.value = false
    if (root.value.hasPointerCapture?.(id)) root.value.releasePointerCapture(id)
    return
  }
  gesture.next = moveSlider(
    value.value,
    gesture.index,
    pointerValue(event),
    bounds.value
  )
  publish(gesture.next)
}
function endPointer(event, cancelled = false) {
  if (!gesture || gesture.id !== event.pointerId) return
  const finished = gesture
  gesture = undefined
  dragging.value = false
  if (root.value.hasPointerCapture?.(event.pointerId))
    root.value.releasePointerCapture(event.pointerId)
  if (disabled.value || inputs.value[finished.index]?.matches(':disabled'))
    return
  if (cancelled) publish(finished.start)
  else emit('commit', finished.next)
}
function keydown(event, index) {
  if (event.defaultPrevented) return
  if (disabled.value || event.target.matches(':disabled')) return
  if (
    !event.shiftKey &&
    !['PageUp', 'PageDown', 'Home', 'End'].includes(event.key)
  )
    return
  const limit = limits(index)
  const increment =
    props.bigStep ??
    (bounds.value.step === 'any'
      ? (bounds.value.max - bounds.value.min) / 10
      : bounds.value.step * 10)
  const candidate = sliderKey(
    event.key,
    values.value[index],
    limit.min,
    limit.max,
    increment,
    rtl()
  )
  if (candidate === undefined) return
  event.preventDefault()
  const next = publish(moveSlider(value.value, index, candidate, bounds.value))
  emit('commit', next)
}
function reset(event) {
  queueMicrotask(() => {
    if (event.defaultPrevented) return
    publish(sliderValue(initialValue, bounds.value))
    nextTick(() =>
      inputs.value.forEach((input, index) => {
        if (input) input.value = values.value[index]
      })
    )
  })
}
function listenForm() {
  form?.removeEventListener('reset', reset)
  form = inputs.value[0]?.form
  form?.addEventListener('reset', reset)
}
onMounted(() => {
  listenForm()
  measureThumb()
  geometryObserver = new ResizeObserver(measureThumb)
  geometryObserver.observe(root.value)
  geometryObserver.observe(thumbMeasure.value)
})
watch(
  () => attrs.class,
  () => nextTick(measureThumb)
)
watch(() => attrs.form, listenForm, { flush: 'post' })
onBeforeUnmount(() => {
  form?.removeEventListener('reset', reset)
  geometryObserver?.disconnect()
})
defineExpose({ inputs })
</script>

<template>
  <span
    ref="root"
    data-slot="slider"
    :class="classes"
    :style="attrs.style"
    :dir="attrs.dir"
    :role="range ? 'group' : undefined"
    :aria-label="range ? attrs['aria-label'] : undefined"
    :aria-labelledby="range ? attrs['aria-labelledby'] : undefined"
    :data-disabled="disabled || undefined"
    :data-dragging="dragging || undefined"
    @pointerdown="pointerDown"
    @pointermove="pointerMove"
    @pointerup="endPointer"
    @pointercancel="endPointer($event, true)"
    @lostpointercapture="endPointer($event, true)"
  >
    <span
      aria-hidden="true"
      data-slot="slider-track"
      class="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700"
      :style="{
        insetInline: 'calc(var(--thumb-size) / 2)',
        transform: 'translateY(-50%)',
        translate: 'none'
      }"
    >
      <span
        data-slot="slider-fill"
        class="absolute h-full rounded-full bg-current"
        :style="fill"
      />
      <span
        v-for="mark in ticks"
        :key="mark.value"
        data-slot="slider-mark"
        class="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/65 dark:bg-gray-950/65 rtl:translate-x-1/2"
        :style="{
          insetInlineStart: `${sliderPercent(mark.value, bounds)}%`,
          transform: 'translate(calc(-50% * var(--slider-direction, 1)), -50%)',
          translate: 'none'
        }"
      />
    </span>
    <span
      ref="thumbMeasure"
      aria-hidden="true"
      class="pointer-events-none invisible absolute h-0"
      style="width: var(--thumb-size)"
    />
    <input
      v-for="(item, index) in values"
      :key="index"
      :ref="(el) => (inputs[index] = el)"
      v-bind="inputAttrs"
      type="range"
      data-slot="slider-thumb"
      :data-index="index"
      :id="index === 0 ? attrs.id : attrs.id ? `${attrs.id}-end` : undefined"
      :name="Array.isArray(name) ? name[index] : name"
      :disabled="disabled"
      :min="bounds.min"
      :max="bounds.max"
      :step="bounds.step"
      :value="item"
      :aria-label="
        range
          ? labels[index] || (index ? 'Maximum value' : 'Minimum value')
          : attrs['aria-label']
      "
      :aria-valuemin="limits(index).min"
      :aria-valuemax="limits(index).max"
      :aria-valuetext="valueText?.(item, index)"
      :style="{ zIndex: index === lastIndex ? 2 : 1 }"
      class="klean-slider-input absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed"
      @input="input($event, index)"
      @change="emit('commit', value)"
      @keydown="keydown($event, index)"
      @focus="lastIndex = index"
    />
    <span
      v-for="mark in ticks.filter((tick) => tick.label !== undefined)"
      :key="`label-${mark.value}`"
      data-slot="slider-mark-label"
      class="pointer-events-none absolute top-full -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 rtl:translate-x-1/2"
      :style="{
        insetInlineStart: `calc(${thumbSize / 2}px + (100% - ${thumbSize}px) * ${sliderPercent(mark.value, bounds) / 100})`
      }"
      >{{ mark.label }}</span
    >
  </span>
</template>

<style scoped>
@layer base {
  [data-slot='slider']:dir(rtl) {
    --slider-direction: -1;
  }
  .klean-slider-input {
    pointer-events: none;
  }
  .klean-slider-input::-webkit-slider-runnable-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-moz-range-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-webkit-slider-thumb {
    appearance: none;
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin-top: calc((6px - var(--thumb-size)) / 2);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input::-moz-range-thumb {
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input:focus-visible::-webkit-slider-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  .klean-slider-input:focus-visible::-moz-range-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  @media (forced-colors: active) {
    [data-slot='slider-track'] {
      background: GrayText;
    }
    [data-slot='slider-fill'] {
      background: Highlight;
    }
    .klean-slider-input::-webkit-slider-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
    .klean-slider-input::-moz-range-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
  }
}
</style>

Install the two decorative icons with npx klean-ui add icon bolt refresh, then compose the application-owned control:

vue
<script setup>
import { computed, ref } from 'vue'
import Slider from './Slider.vue'
import Bolt from '../icons/Bolt.vue'
import Refresh from '../icons/Refresh.vue'

const effort = ref(2)
const levels = ['Low', 'Medium', 'High', 'Very high', 'Ultra']
const ultra = computed(() => effort.value === 5)
const accentClasses = computed(() =>
  ultra.value
    ? 'text-violet-600 dark:text-violet-400'
    : 'text-blue-500 dark:text-blue-400'
)
const sliderClasses = computed(() =>
  [
    'h-16 [--thumb-size:3.5rem]',
    accentClasses.value,
    '**:data-[slot=slider-track]:h-12 **:data-[slot=slider-track]:bg-gray-100 **:data-[slot=slider-track]:ring-1 **:data-[slot=slider-track]:ring-gray-200',
    '**:data-[slot=slider-mark]:size-2 **:data-[slot=slider-mark]:bg-gray-400/65',
    '[&>input::-webkit-slider-thumb]:border-gray-200 [&>input::-webkit-slider-thumb]:shadow-md',
    '[&>input::-moz-range-thumb]:border-gray-200 [&>input::-moz-range-thumb]:shadow-md',
    ultra.value
      ? '**:data-[slot=slider-fill]:bg-[linear-gradient(90deg,#2563eb,#a78bfa,#7c3aed)] **:data-[slot=slider-mark]:opacity-0'
      : ''
  ].join(' ')
)
</script>

<template>
  <section
    class="w-full max-w-md rounded-3xl border border-gray-200 bg-white px-6 py-5 shadow-sm dark:border-gray-700 dark:bg-gray-950"
    aria-labelledby="effort-title"
  >
    <div class="mb-5 grid grid-cols-[2.75rem_1fr_2.75rem] items-start gap-3">
      <Bolt class="mt-2 size-6" :class="accentClasses" />
      <div class="text-center">
        <label id="effort-title" for="effort-slider" class="sr-only"
          >Thinking effort</label
        >
        <output
          for="effort-slider"
          class="block text-2xl font-medium"
          :class="accentClasses"
          >{{ levels[effort - 1] }}</output
        >
        <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
          Thinking effort
        </p>
      </div>
      <button
        type="button"
        aria-label="Reset thinking effort to Medium"
        class="grid size-11 cursor-pointer place-items-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-gray-800 dark:hover:text-white"
        @click="effort = 2"
      >
        <Refresh class="size-6" />
      </button>
    </div>
    <Slider
      id="effort-slider"
      v-model="effort"
      :min="1"
      :max="5"
      :marks="[2, 3, 4]"
      :value-text="(value) => levels[value - 1]"
      :class="sliderClasses"
    />
  </section>
</template>

The same classes apply to React's className and Svelte's class. Set the CSS thumb size with [--thumb-size:3.5rem]; the track and pointer positioning stay aligned with it. Filled color, marks, and surrounding content do not require a new variant or a theme API. Keep a visible focus indicator and an accessible name when changing the presentation.

Accessible labels and errors ​

Associate a visible <label> with a single slider using for and id. Use aria-label when a visible label is not available. For ranges, label the group with aria-label or aria-labelledby and supply distinct endpoint labels.

Use valueText when a plain number is not enough—for example “40 percent”, “Balanced”, or a formatted currency amount. Keep the same unit visible beside the control.

Set aria-invalid and connect an error message with aria-describedby for application validation. Do not communicate errors through color alone. disabled removes unavailable controls from the tab order; their values remain visible.

Arrow keys adjust by one step. Home and End reach the allowed endpoints. Shift with an arrow, or Page Up and Page Down, adjusts by ten steps; bigStep overrides that increment. Direction follows the surrounding dir setting.

Durable application state ​

A slider does not decide where its value belongs. Keep shareable filters in the URL and configuration changes in the application's form or saved record. Update visible values immediately; persist or navigate after a commit when appropriate. On failure, keep the value and an actionable error visible.

Source ​

vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
import {
  moveSlider,
  sliderBounds,
  sliderKey,
  sliderPercent,
  sliderValue
} from './slider.js'

defineOptions({ inheritAttrs: false })
const props = defineProps({
  modelValue: { type: [Number, Array], default: undefined },
  min: { type: Number, default: 0 },
  max: { type: Number, default: 100 },
  step: { type: [Number, String], default: 1 },
  minStepsBetween: { type: Number, default: 0 },
  bigStep: { type: Number, default: undefined },
  labels: { type: Array, default: () => ['Minimum value', 'Maximum value'] },
  marks: { type: Array, default: () => [] },
  valueText: { type: Function, default: undefined },
  disabled: Boolean,
  name: { type: [String, Array], default: undefined }
})
const emit = defineEmits(['update:modelValue', 'change', 'commit'])
const attrs = useAttrs()
const root = ref()
const inputs = ref([])
const bounds = computed(() =>
  sliderBounds(props.min, props.max, props.step, props.minStepsBetween)
)
const value = computed(() => sliderValue(props.modelValue, bounds.value))
const values = computed(() =>
  Array.isArray(value.value) ? value.value : [value.value]
)
const range = computed(() => values.value.length === 2)
const disabled = computed(
  () => props.disabled || bounds.value.min === bounds.value.max
)
const dragging = ref(false)
const thumbSize = ref(20)
const thumbMeasure = ref()
let geometryObserver
function measureThumb() {
  const width = thumbMeasure.value?.getBoundingClientRect().width
  if (Number.isFinite(width) && width > 0) thumbSize.value = width
}
const initialValue = Array.isArray(props.modelValue)
  ? [...props.modelValue]
  : props.modelValue
let gesture
let form
const lastIndex = ref(0)
const classes = computed(() =>
  twMerge(
    'relative block h-11 w-full touch-pan-y select-none text-gray-950 dark:text-white data-disabled:cursor-not-allowed data-disabled:opacity-40 [--thumb-size:1.25rem]',
    attrs.class
  )
)
const inputAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    dir: _dir,
    id: _id,
    'aria-label': _label,
    ...rest
  } = attrs
  if (range.value) delete rest['aria-labelledby']
  return rest
})
const fill = computed(() => ({
  insetInlineStart: `${range.value ? sliderPercent(values.value[0], bounds.value) : 0}%`,
  width: `${sliderPercent(values.value.at(-1), bounds.value) - (range.value ? sliderPercent(values.value[0], bounds.value) : 0)}%`
}))
const ticks = computed(() =>
  props.marks
    .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
    .filter(
      (mark) =>
        Number.isFinite(mark.value) &&
        mark.value >= bounds.value.min &&
        mark.value <= bounds.value.max
    )
)
const limits = (index) => ({
  min: index === 1 ? values.value[0] + bounds.value.gap : bounds.value.min,
  max:
    range.value && index === 0
      ? values.value[1] - bounds.value.gap
      : bounds.value.max
})
function publish(next) {
  emit('update:modelValue', next)
  emit('change', next)
  return next
}
function input(event, index) {
  const next = moveSlider(
    value.value,
    index,
    event.target.valueAsNumber,
    bounds.value
  )
  event.target.value = Array.isArray(next) ? next[index] : next
  publish(next)
  nextTick(() => {
    event.target.value = values.value[index]
  })
}
function rtl() {
  return getComputedStyle(root.value).direction === 'rtl'
}
function pointerValue(event) {
  measureThumb()
  const rect = root.value.getBoundingClientRect()
  const fraction = Math.max(
    0,
    Math.min(
      1,
      (event.clientX - rect.left - thumbSize.value / 2) /
        Math.max(1, rect.width - thumbSize.value)
    )
  )
  return (
    bounds.value.min +
    (rtl() ? 1 - fraction : fraction) * (bounds.value.max - bounds.value.min)
  )
}
function pointerDown(event) {
  if (
    disabled.value ||
    inputs.value[0]?.matches(':disabled') ||
    event.button !== 0 ||
    event.isPrimary === false
  )
    return
  event.preventDefault()
  const candidate = pointerValue(event)
  const distances = values.value.map((item) => Math.abs(item - candidate))
  const index = range.value
    ? distances[0] === distances[1]
      ? candidate < values.value[0]
        ? 0
        : candidate > values.value[1]
          ? 1
          : lastIndex.value === 0
            ? 1
            : 0
      : distances[0] < distances[1]
        ? 0
        : 1
    : 0
  lastIndex.value = index
  inputs.value[index]?.focus()
  gesture = {
    id: event.pointerId,
    index,
    start: value.value,
    next: value.value
  }
  root.value.setPointerCapture?.(event.pointerId)
  dragging.value = true
  pointerMove(event)
}
function pointerMove(event) {
  if (!gesture || gesture.id !== event.pointerId) return
  if (disabled.value || inputs.value[gesture.index]?.matches(':disabled')) {
    const id = gesture.id
    gesture = undefined
    dragging.value = false
    if (root.value.hasPointerCapture?.(id)) root.value.releasePointerCapture(id)
    return
  }
  gesture.next = moveSlider(
    value.value,
    gesture.index,
    pointerValue(event),
    bounds.value
  )
  publish(gesture.next)
}
function endPointer(event, cancelled = false) {
  if (!gesture || gesture.id !== event.pointerId) return
  const finished = gesture
  gesture = undefined
  dragging.value = false
  if (root.value.hasPointerCapture?.(event.pointerId))
    root.value.releasePointerCapture(event.pointerId)
  if (disabled.value || inputs.value[finished.index]?.matches(':disabled'))
    return
  if (cancelled) publish(finished.start)
  else emit('commit', finished.next)
}
function keydown(event, index) {
  if (event.defaultPrevented) return
  if (disabled.value || event.target.matches(':disabled')) return
  if (
    !event.shiftKey &&
    !['PageUp', 'PageDown', 'Home', 'End'].includes(event.key)
  )
    return
  const limit = limits(index)
  const increment =
    props.bigStep ??
    (bounds.value.step === 'any'
      ? (bounds.value.max - bounds.value.min) / 10
      : bounds.value.step * 10)
  const candidate = sliderKey(
    event.key,
    values.value[index],
    limit.min,
    limit.max,
    increment,
    rtl()
  )
  if (candidate === undefined) return
  event.preventDefault()
  const next = publish(moveSlider(value.value, index, candidate, bounds.value))
  emit('commit', next)
}
function reset(event) {
  queueMicrotask(() => {
    if (event.defaultPrevented) return
    publish(sliderValue(initialValue, bounds.value))
    nextTick(() =>
      inputs.value.forEach((input, index) => {
        if (input) input.value = values.value[index]
      })
    )
  })
}
function listenForm() {
  form?.removeEventListener('reset', reset)
  form = inputs.value[0]?.form
  form?.addEventListener('reset', reset)
}
onMounted(() => {
  listenForm()
  measureThumb()
  geometryObserver = new ResizeObserver(measureThumb)
  geometryObserver.observe(root.value)
  geometryObserver.observe(thumbMeasure.value)
})
watch(
  () => attrs.class,
  () => nextTick(measureThumb)
)
watch(() => attrs.form, listenForm, { flush: 'post' })
onBeforeUnmount(() => {
  form?.removeEventListener('reset', reset)
  geometryObserver?.disconnect()
})
defineExpose({ inputs })
</script>

<template>
  <span
    ref="root"
    data-slot="slider"
    :class="classes"
    :style="attrs.style"
    :dir="attrs.dir"
    :role="range ? 'group' : undefined"
    :aria-label="range ? attrs['aria-label'] : undefined"
    :aria-labelledby="range ? attrs['aria-labelledby'] : undefined"
    :data-disabled="disabled || undefined"
    :data-dragging="dragging || undefined"
    @pointerdown="pointerDown"
    @pointermove="pointerMove"
    @pointerup="endPointer"
    @pointercancel="endPointer($event, true)"
    @lostpointercapture="endPointer($event, true)"
  >
    <span
      aria-hidden="true"
      data-slot="slider-track"
      class="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700"
      :style="{
        insetInline: 'calc(var(--thumb-size) / 2)',
        transform: 'translateY(-50%)',
        translate: 'none'
      }"
    >
      <span
        data-slot="slider-fill"
        class="absolute h-full rounded-full bg-current"
        :style="fill"
      />
      <span
        v-for="mark in ticks"
        :key="mark.value"
        data-slot="slider-mark"
        class="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/65 dark:bg-gray-950/65 rtl:translate-x-1/2"
        :style="{
          insetInlineStart: `${sliderPercent(mark.value, bounds)}%`,
          transform: 'translate(calc(-50% * var(--slider-direction, 1)), -50%)',
          translate: 'none'
        }"
      />
    </span>
    <span
      ref="thumbMeasure"
      aria-hidden="true"
      class="pointer-events-none invisible absolute h-0"
      style="width: var(--thumb-size)"
    />
    <input
      v-for="(item, index) in values"
      :key="index"
      :ref="(el) => (inputs[index] = el)"
      v-bind="inputAttrs"
      type="range"
      data-slot="slider-thumb"
      :data-index="index"
      :id="index === 0 ? attrs.id : attrs.id ? `${attrs.id}-end` : undefined"
      :name="Array.isArray(name) ? name[index] : name"
      :disabled="disabled"
      :min="bounds.min"
      :max="bounds.max"
      :step="bounds.step"
      :value="item"
      :aria-label="
        range
          ? labels[index] || (index ? 'Maximum value' : 'Minimum value')
          : attrs['aria-label']
      "
      :aria-valuemin="limits(index).min"
      :aria-valuemax="limits(index).max"
      :aria-valuetext="valueText?.(item, index)"
      :style="{ zIndex: index === lastIndex ? 2 : 1 }"
      class="klean-slider-input absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed"
      @input="input($event, index)"
      @change="emit('commit', value)"
      @keydown="keydown($event, index)"
      @focus="lastIndex = index"
    />
    <span
      v-for="mark in ticks.filter((tick) => tick.label !== undefined)"
      :key="`label-${mark.value}`"
      data-slot="slider-mark-label"
      class="pointer-events-none absolute top-full -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 rtl:translate-x-1/2"
      :style="{
        insetInlineStart: `calc(${thumbSize / 2}px + (100% - ${thumbSize}px) * ${sliderPercent(mark.value, bounds) / 100})`
      }"
      >{{ mark.label }}</span
    >
  </span>
</template>

<style scoped>
@layer base {
  [data-slot='slider']:dir(rtl) {
    --slider-direction: -1;
  }
  .klean-slider-input {
    pointer-events: none;
  }
  .klean-slider-input::-webkit-slider-runnable-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-moz-range-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-webkit-slider-thumb {
    appearance: none;
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin-top: calc((6px - var(--thumb-size)) / 2);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input::-moz-range-thumb {
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input:focus-visible::-webkit-slider-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  .klean-slider-input:focus-visible::-moz-range-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  @media (forced-colors: active) {
    [data-slot='slider-track'] {
      background: GrayText;
    }
    [data-slot='slider-fill'] {
      background: Highlight;
    }
    .klean-slider-input::-webkit-slider-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
    .klean-slider-input::-moz-range-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
  }
}
</style>
jsx
import React, { forwardRef, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import {
  moveSlider,
  sliderBounds,
  sliderKey,
  sliderPercent,
  sliderValue
} from './slider.js'
import './slider.css'

const Slider = forwardRef(function Slider(
  {
    value: controlledValue,
    defaultValue,
    onChange,
    onCommit,
    min = 0,
    max = 100,
    step = 1,
    minStepsBetween = 0,
    bigStep,
    labels = ['Minimum value', 'Maximum value'],
    marks = [],
    valueText,
    disabled: unavailable = false,
    name,
    className,
    style,
    dir,
    id,
    'aria-label': label,
    'aria-labelledby': labelledBy,
    onInput,
    onKeyDown,
    onFocus,
    ...inputProps
  },
  forwardedRef
) {
  const [localValue, setLocalValue] = useState(defaultValue)
  const initialValue = useRef(controlledValue ?? defaultValue)
  const bounds = sliderBounds(min, max, step, minStepsBetween)
  const value = sliderValue(
    controlledValue === undefined ? localValue : controlledValue,
    bounds
  )
  const values = Array.isArray(value) ? value : [value]
  const range = values.length === 2
  const disabled = unavailable || bounds.min === bounds.max
  const root = useRef(null)
  const inputs = useRef([])
  const thumbMeasure = useRef(null)
  const gesture = useRef()
  const latest = useRef()
  const [dragging, setDragging] = useState(false)
  const [lastIndex, setLastIndex] = useState(0)
  const [thumbSize, setThumbSize] = useState(20)
  latest.current = {
    value,
    values,
    bounds,
    onChange,
    onCommit,
    controlledValue
  }
  function publish(next) {
    if (controlledValue === undefined) setLocalValue(next)
    onChange?.(next)
    return next
  }
  function measureThumb() {
    const width = thumbMeasure.current?.getBoundingClientRect().width
    if (Number.isFinite(width) && width > 0) setThumbSize(width)
    return Number.isFinite(width) && width > 0 ? width : thumbSize
  }
  useEffect(() => {
    measureThumb()
    const observer = new ResizeObserver(measureThumb)
    observer.observe(root.current)
    observer.observe(thumbMeasure.current)
    return () => observer.disconnect()
  }, [])
  useEffect(() => {
    measureThumb()
  }, [className, style])
  useEffect(() => {
    const elements = inputs.current.filter(Boolean)
    const commit = () => latest.current.onCommit?.(latest.current.value)
    elements.forEach((element) => element.addEventListener('change', commit))
    return () =>
      elements.forEach((element) =>
        element.removeEventListener('change', commit)
      )
  }, [range])
  useEffect(() => {
    const form = inputs.current[0]?.form
    function reset(event) {
      queueMicrotask(() => {
        if (event.defaultPrevented) return
        const current = latest.current
        const next = sliderValue(initialValue.current, current.bounds)
        if (current.controlledValue === undefined) setLocalValue(next)
        current.onChange?.(next)
        // Controlled callers may reject reset; keep the DOM authoritative too.
        queueMicrotask(() =>
          inputs.current.forEach((input, index) => {
            if (input) input.value = latest.current.values[index]
          })
        )
      })
    }
    form?.addEventListener('reset', reset)
    return () => form?.removeEventListener('reset', reset)
  }, [inputProps.form])
  function limits(index) {
    return {
      min: index === 1 ? values[0] + bounds.gap : bounds.min,
      max: range && index === 0 ? values[1] - bounds.gap : bounds.max
    }
  }
  function rtl() {
    return getComputedStyle(root.current).direction === 'rtl'
  }
  function pointerValue(event) {
    const measuredWidth = measureThumb()
    const rect = root.current.getBoundingClientRect()
    const fraction = Math.max(
      0,
      Math.min(
        1,
        (event.clientX - rect.left - measuredWidth / 2) /
          Math.max(1, rect.width - measuredWidth)
      )
    )
    return (
      bounds.min + (rtl() ? 1 - fraction : fraction) * (bounds.max - bounds.min)
    )
  }
  function pointerDown(event) {
    if (
      disabled ||
      inputs.current[0]?.matches(':disabled') ||
      event.button !== 0 ||
      event.isPrimary === false
    )
      return
    event.preventDefault()
    const candidate = pointerValue(event)
    const distances = values.map((item) => Math.abs(item - candidate))
    const index = range
      ? distances[0] === distances[1]
        ? candidate < values[0]
          ? 0
          : candidate > values[1]
            ? 1
            : lastIndex === 0
              ? 1
              : 0
        : distances[0] < distances[1]
          ? 0
          : 1
      : 0
    setLastIndex(index)
    inputs.current[index]?.focus()
    gesture.current = { id: event.pointerId, index, start: value, next: value }
    root.current.setPointerCapture?.(event.pointerId)
    setDragging(true)
    pointerMove(event)
  }
  function pointerMove(event) {
    const active = gesture.current
    if (!active || active.id !== event.pointerId) return
    if (disabled || inputs.current[active.index]?.matches(':disabled')) {
      gesture.current = undefined
      setDragging(false)
      if (root.current.hasPointerCapture?.(active.id))
        root.current.releasePointerCapture(active.id)
      return
    }
    active.next = moveSlider(value, active.index, pointerValue(event), bounds)
    publish(active.next)
  }
  function endPointer(event, cancelled = false) {
    const active = gesture.current
    if (!active || active.id !== event.pointerId) return
    gesture.current = undefined
    setDragging(false)
    if (root.current.hasPointerCapture?.(event.pointerId))
      root.current.releasePointerCapture(event.pointerId)
    if (disabled || inputs.current[active.index]?.matches(':disabled')) return
    if (cancelled) publish(active.start)
    else onCommit?.(active.next)
  }
  function keydown(event, index) {
    onKeyDown?.(event)
    if (event.defaultPrevented || disabled || event.target.matches(':disabled'))
      return
    if (
      !event.shiftKey &&
      !['PageUp', 'PageDown', 'Home', 'End'].includes(event.key)
    )
      return
    const limit = limits(index)
    const increment =
      bigStep ??
      (bounds.step === 'any'
        ? (bounds.max - bounds.min) / 10
        : bounds.step * 10)
    const candidate = sliderKey(
      event.key,
      values[index],
      limit.min,
      limit.max,
      increment,
      rtl()
    )
    if (candidate === undefined) return
    event.preventDefault()
    const next = publish(moveSlider(value, index, candidate, bounds))
    onCommit?.(next)
  }
  function input(event, index) {
    onInput?.(event)
    if (event.defaultPrevented) return
    const next = moveSlider(value, index, event.target.valueAsNumber, bounds)
    event.target.value = Array.isArray(next) ? next[index] : next
    publish(next)
    const target = event.target
    queueMicrotask(() => {
      target.value = latest.current.values[index]
    })
  }
  const ticks = marks
    .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
    .filter(
      (mark) =>
        Number.isFinite(mark.value) &&
        mark.value >= bounds.min &&
        mark.value <= bounds.max
    )
  const start = range ? sliderPercent(values[0], bounds) : 0
  return (
    <span
      ref={root}
      data-slot="slider"
      className={twMerge(
        'relative block h-11 w-full touch-pan-y select-none text-gray-950 dark:text-white data-disabled:cursor-not-allowed data-disabled:opacity-40 [--thumb-size:1.25rem]',
        className
      )}
      style={style}
      dir={dir}
      role={range ? 'group' : undefined}
      aria-label={range ? label : undefined}
      aria-labelledby={range ? labelledBy : undefined}
      data-disabled={disabled || undefined}
      data-dragging={dragging || undefined}
      onPointerDown={pointerDown}
      onPointerMove={pointerMove}
      onPointerUp={endPointer}
      onPointerCancel={(event) => endPointer(event, true)}
      onLostPointerCapture={(event) => endPointer(event, true)}
    >
      <span
        aria-hidden="true"
        data-slot="slider-track"
        className="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
        style={{
          insetInline: 'calc(var(--thumb-size) / 2)',
          transform: 'translateY(-50%)',
          translate: 'none'
        }}
      >
        <span
          data-slot="slider-fill"
          className="absolute h-full rounded-full bg-current"
          style={{
            insetInlineStart: `${start}%`,
            width: `${sliderPercent(values.at(-1), bounds) - start}%`
          }}
        />
        {ticks.map((mark) => (
          <span
            key={mark.value}
            data-slot="slider-mark"
            className="absolute top-1/2 size-1 rounded-full bg-white/65 dark:bg-gray-950/65"
            style={{
              insetInlineStart: `${sliderPercent(mark.value, bounds)}%`,
              transform:
                'translate(calc(-50% * var(--slider-direction, 1)), -50%)',
              translate: 'none'
            }}
          />
        ))}
      </span>
      <span
        ref={thumbMeasure}
        aria-hidden="true"
        className="pointer-events-none invisible absolute h-0"
        style={{ width: 'var(--thumb-size)' }}
      />
      {values.map((item, index) => (
        <input
          key={index}
          {...inputProps}
          ref={(element) => {
            inputs.current[index] = element
            if (index === 0) {
              if (typeof forwardedRef === 'function') forwardedRef(element)
              else if (forwardedRef) forwardedRef.current = element
            }
          }}
          type="range"
          data-slot="slider-thumb"
          data-index={index}
          id={index === 0 ? id : id ? `${id}-end` : undefined}
          name={Array.isArray(name) ? name[index] : name}
          disabled={disabled}
          min={bounds.min}
          max={bounds.max}
          step={bounds.step}
          value={item}
          aria-label={
            range
              ? labels[index] || (index ? 'Maximum value' : 'Minimum value')
              : label
          }
          aria-labelledby={range ? undefined : labelledBy}
          aria-valuemin={limits(index).min}
          aria-valuemax={limits(index).max}
          aria-valuetext={valueText?.(item, index)}
          style={{ zIndex: index === lastIndex ? 2 : 1 }}
          className="klean-slider-input absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed"
          onChange={(event) => input(event, index)}
          onKeyDown={(event) => keydown(event, index)}
          onFocus={(event) => {
            setLastIndex(index)
            onFocus?.(event)
          }}
        />
      ))}
      {ticks
        .filter((mark) => mark.label !== undefined)
        .map((mark) => (
          <span
            key={`label-${mark.value}`}
            data-slot="slider-mark-label"
            className="pointer-events-none absolute top-full -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 rtl:translate-x-1/2"
            style={{
              insetInlineStart: `calc(${thumbSize / 2}px + (100% - ${thumbSize}px) * ${sliderPercent(mark.value, bounds) / 100})`
            }}
          >
            {mark.label}
          </span>
        ))}
    </span>
  )
})

export default Slider
svelte
<script>
  import { onMount, tick, untrack } from 'svelte'
  import { twMerge } from 'tailwind-merge'
  import {
    moveSlider,
    sliderBounds,
    sliderKey,
    sliderPercent,
    sliderValue
  } from './slider.js'

  let {
    value = $bindable(),
    min = 0,
    max = 100,
    step = 1,
    minStepsBetween = 0,
    bigStep,
    labels = ['Minimum value', 'Maximum value'],
    marks = [],
    valueText,
    disabled: unavailable = false,
    name,
    class: className = '',
    style,
    dir,
    id,
    'aria-label': label,
    'aria-labelledby': labelledBy,
    onchange,
    oncommit,
    oninput,
    onkeydown,
    onfocus,
    ...inputProps
  } = $props()
  let root = $state()
  let thumbMeasure = $state()
  let inputs = $state([])
  let thumbSize = $state(20)
  let dragging = $state(false)
  let lastIndex = $state(0)
  let gesture
  const initialValue = untrack(() =>
    Array.isArray(value) ? [...value] : value
  )
  let bounds = $derived(sliderBounds(min, max, step, minStepsBetween))
  let current = $derived(sliderValue(value, bounds))
  let values = $derived(Array.isArray(current) ? current : [current])
  let range = $derived(values.length === 2)
  let disabled = $derived(unavailable || bounds.min === bounds.max)
  let start = $derived(range ? sliderPercent(values[0], bounds) : 0)
  let ticks = $derived(
    marks
      .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
      .filter(
        (mark) =>
          Number.isFinite(mark.value) &&
          mark.value >= bounds.min &&
          mark.value <= bounds.max
      )
  )
  function publish(next) {
    value = next
    onchange?.(next)
    return next
  }
  function measureThumb() {
    const width = thumbMeasure?.getBoundingClientRect().width
    if (Number.isFinite(width) && width > 0) thumbSize = width
  }
  onMount(() => {
    measureThumb()
    const observer = new ResizeObserver(measureThumb)
    observer.observe(root)
    observer.observe(thumbMeasure)
    return () => observer.disconnect()
  })
  $effect(() => {
    className
    style
    tick().then(measureThumb)
  })
  $effect(() => {
    inputProps.form
    const form = inputs[0]?.form
    function reset(event) {
      queueMicrotask(() => {
        if (event.defaultPrevented) return
        publish(sliderValue(initialValue, bounds))
        tick().then(() =>
          inputs.forEach((input, index) => {
            if (input) input.value = values[index]
          })
        )
      })
    }
    form?.addEventListener('reset', reset)
    return () => form?.removeEventListener('reset', reset)
  })
  function limits(index) {
    return {
      min: index === 1 ? values[0] + bounds.gap : bounds.min,
      max: range && index === 0 ? values[1] - bounds.gap : bounds.max
    }
  }
  function rtl() {
    return getComputedStyle(root).direction === 'rtl'
  }
  function pointerValue(event) {
    measureThumb()
    const rect = root.getBoundingClientRect()
    const fraction = Math.max(
      0,
      Math.min(
        1,
        (event.clientX - rect.left - thumbSize / 2) /
          Math.max(1, rect.width - thumbSize)
      )
    )
    return (
      bounds.min + (rtl() ? 1 - fraction : fraction) * (bounds.max - bounds.min)
    )
  }
  function pointerDown(event) {
    if (
      disabled ||
      inputs[0]?.matches(':disabled') ||
      event.button !== 0 ||
      event.isPrimary === false
    )
      return
    event.preventDefault()
    const candidate = pointerValue(event)
    const distances = values.map((item) => Math.abs(item - candidate))
    const index = range
      ? distances[0] === distances[1]
        ? candidate < values[0]
          ? 0
          : candidate > values[1]
            ? 1
            : lastIndex === 0
              ? 1
              : 0
        : distances[0] < distances[1]
          ? 0
          : 1
      : 0
    lastIndex = index
    inputs[index]?.focus()
    gesture = { id: event.pointerId, index, start: current, next: current }
    root.setPointerCapture?.(event.pointerId)
    dragging = true
    pointerMove(event)
  }
  function pointerMove(event) {
    if (!gesture || gesture.id !== event.pointerId) return
    if (disabled || inputs[gesture.index]?.matches(':disabled')) {
      const id = gesture.id
      gesture = undefined
      dragging = false
      if (root.hasPointerCapture?.(id)) root.releasePointerCapture(id)
      return
    }
    gesture.next = moveSlider(
      current,
      gesture.index,
      pointerValue(event),
      bounds
    )
    publish(gesture.next)
  }
  function endPointer(event, cancelled = false) {
    if (!gesture || gesture.id !== event.pointerId) return
    const finished = gesture
    gesture = undefined
    dragging = false
    if (root.hasPointerCapture?.(event.pointerId))
      root.releasePointerCapture(event.pointerId)
    if (disabled || inputs[finished.index]?.matches(':disabled')) return
    if (cancelled) publish(finished.start)
    else oncommit?.(finished.next)
  }
  function keydown(event, index) {
    onkeydown?.(event)
    if (event.defaultPrevented || disabled || event.target.matches(':disabled'))
      return
    if (
      !event.shiftKey &&
      !['PageUp', 'PageDown', 'Home', 'End'].includes(event.key)
    )
      return
    const limit = limits(index)
    const increment =
      bigStep ??
      (bounds.step === 'any'
        ? (bounds.max - bounds.min) / 10
        : bounds.step * 10)
    const candidate = sliderKey(
      event.key,
      values[index],
      limit.min,
      limit.max,
      increment,
      rtl()
    )
    if (candidate === undefined) return
    event.preventDefault()
    const next = publish(moveSlider(current, index, candidate, bounds))
    oncommit?.(next)
  }
  function input(event, index) {
    oninput?.(event)
    if (event.defaultPrevented) return
    publish(moveSlider(current, index, event.target.valueAsNumber, bounds))
    tick().then(() => {
      event.target.value = values[index]
    })
  }
</script>

<!-- Pointer handling complements the native, labelled range inputs; this wrapper is not a separate control. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
  bind:this={root}
  data-slot="slider"
  class={twMerge(
    'relative block h-11 w-full touch-pan-y select-none text-gray-950 dark:text-white data-disabled:cursor-not-allowed data-disabled:opacity-40 [--thumb-size:1.25rem]',
    className
  )}
  {style}
  {dir}
  role={range ? 'group' : undefined}
  aria-label={range ? label : undefined}
  aria-labelledby={range ? labelledBy : undefined}
  data-disabled={disabled || undefined}
  data-dragging={dragging || undefined}
  onpointerdown={pointerDown}
  onpointermove={pointerMove}
  onpointerup={endPointer}
  onpointercancel={(event) => endPointer(event, true)}
  onlostpointercapture={(event) => endPointer(event, true)}
>
  <span
    aria-hidden="true"
    data-slot="slider-track"
    class="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
    style="inset-inline: calc(var(--thumb-size) / 2); transform: translateY(-50%); translate: none"
  >
    <span
      data-slot="slider-fill"
      class="absolute h-full rounded-full bg-current"
      style:inset-inline-start={`${start}%`}
      style:width={`${sliderPercent(values.at(-1), bounds) - start}%`}
    ></span>
    {#each ticks as mark (mark.value)}
      <span
        data-slot="slider-mark"
        class="absolute top-1/2 size-1 rounded-full bg-white/65 dark:bg-gray-950/65"
        style:inset-inline-start={`${sliderPercent(mark.value, bounds)}%`}
        style="transform: translate(calc(-50% * var(--slider-direction, 1)), -50%); translate: none"
      ></span>
    {/each}
  </span>
  <span
    bind:this={thumbMeasure}
    aria-hidden="true"
    class="pointer-events-none invisible absolute h-0"
    style="width: var(--thumb-size)"
  ></span>
  {#each values as item, index (index)}
    <input
      {...inputProps}
      bind:this={inputs[index]}
      type="range"
      data-slot="slider-thumb"
      data-index={index}
      id={index === 0 ? id : id ? `${id}-end` : undefined}
      name={Array.isArray(name) ? name[index] : name}
      {disabled}
      min={bounds.min}
      max={bounds.max}
      step={bounds.step}
      value={item}
      aria-label={range
        ? labels[index] || (index ? 'Maximum value' : 'Minimum value')
        : label}
      aria-labelledby={range ? undefined : labelledBy}
      aria-valuemin={limits(index).min}
      aria-valuemax={limits(index).max}
      aria-valuetext={valueText?.(item, index)}
      style:z-index={index === lastIndex ? 2 : 1}
      class="klean-slider-input absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed"
      oninput={(event) => input(event, index)}
      onchange={() => oncommit?.(current)}
      onkeydown={(event) => keydown(event, index)}
      onfocus={(event) => {
        lastIndex = index
        onfocus?.(event)
      }}
    />
  {/each}
  {#each ticks.filter((mark) => mark.label !== undefined) as mark (mark.value)}
    <span
      data-slot="slider-mark-label"
      class="pointer-events-none absolute top-full -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 rtl:translate-x-1/2"
      style:inset-inline-start={`calc(${thumbSize / 2}px + (100% - ${thumbSize}px) * ${sliderPercent(mark.value, bounds) / 100})`}
      >{mark.label}</span
    >
  {/each}
</span>

<style>
  @layer base {
    [data-slot='slider']:dir(rtl) {
      --slider-direction: -1;
    }
    .klean-slider-input {
      pointer-events: none;
    }
    .klean-slider-input::-webkit-slider-runnable-track {
      height: 6px;
      background: transparent;
    }
    .klean-slider-input::-moz-range-track {
      height: 6px;
      background: transparent;
    }
    .klean-slider-input::-webkit-slider-thumb {
      appearance: none;
      box-sizing: border-box;
      width: var(--thumb-size);
      height: var(--thumb-size);
      margin-top: calc((6px - var(--thumb-size)) / 2);
      border: 1px solid #d1d5db;
      border-radius: 50%;
      background: #fff;
      box-shadow: 0 1px 3px #0002;
      pointer-events: auto;
    }
    .klean-slider-input::-moz-range-thumb {
      box-sizing: border-box;
      width: var(--thumb-size);
      height: var(--thumb-size);
      border: 1px solid #d1d5db;
      border-radius: 50%;
      background: #fff;
      box-shadow: 0 1px 3px #0002;
      pointer-events: auto;
    }
    .klean-slider-input:focus-visible::-webkit-slider-thumb {
      outline: 3px solid currentColor;
      outline-offset: 3px;
    }
    .klean-slider-input:focus-visible::-moz-range-thumb {
      outline: 3px solid currentColor;
      outline-offset: 3px;
    }
    @media (forced-colors: active) {
      [data-slot='slider-track'] {
        background: GrayText;
      }
      [data-slot='slider-fill'] {
        background: Highlight;
      }
      .klean-slider-input::-webkit-slider-thumb {
        border-color: ButtonText;
        background: ButtonFace;
      }
      .klean-slider-input::-moz-range-thumb {
        border-color: ButtonText;
        background: ButtonFace;
      }
    }
  }
</style>

Each implementation uses the accompanying numeric helpers. The React implementation also imports the accompanying stylesheet.

js
const finite = (value, fallback) =>
  Number.isFinite(Number(value)) ? Number(value) : fallback
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
const tidy = (value) => Number(value.toPrecision(12))

export function sliderBounds(
  min = 0,
  max = 100,
  step = 1,
  minStepsBetween = 0
) {
  min = finite(min, 0)
  max = Math.max(min, finite(max, 100))
  const numericStep = finite(step, 1)
  step =
    step === 'any'
      ? 'any'
      : Math.max(Number.EPSILON, numericStep > 0 ? numericStep : 1)
  const end =
    step === 'any'
      ? max
      : tidy(min + Math.floor((max - min) / step + 1e-10) * step)
  const gap =
    step === 'any'
      ? 0
      : tidy(
          Math.min(
            end - min,
            Math.max(0, Math.floor(finite(minStepsBetween, 0))) * step
          )
        )
  return { min, max: end, step, gap }
}

export function snap(value, bounds) {
  const { min, max, step } = bounds
  value = clamp(finite(value, min + (max - min) / 2), min, max)
  return step === 'any'
    ? value
    : clamp(tidy(min + Math.round((value - min) / step) * step), min, max)
}

export function sliderValue(value, bounds) {
  if (!Array.isArray(value) || value.length !== 2)
    return snap(Array.isArray(value) ? undefined : value, bounds)
  const sorted = value.map((item) => snap(item, bounds)).sort((a, b) => a - b)
  if (sorted[1] - sorted[0] < bounds.gap) {
    sorted[1] = Math.min(bounds.max, tidy(sorted[0] + bounds.gap))
    sorted[0] = tidy(sorted[1] - bounds.gap)
  }
  return sorted
}

export function moveSlider(value, index, candidate, bounds) {
  if (!Array.isArray(value)) return snap(candidate, bounds)
  const next = [...value]
  const min = index === 0 ? bounds.min : tidy(value[0] + bounds.gap)
  const max = index === 0 ? tidy(value[1] - bounds.gap) : bounds.max
  next[index] = clamp(snap(candidate, bounds), min, max)
  return next
}

export function sliderPercent(value, bounds) {
  return bounds.max === bounds.min
    ? 0
    : ((value - bounds.min) / (bounds.max - bounds.min)) * 100
}

export function sliderKey(key, value, min, max, increment, rtl = false) {
  if (key === 'Home') return min
  if (key === 'End') return max
  if (key === 'PageUp' || key === 'ArrowUp') return value + increment
  if (key === 'PageDown' || key === 'ArrowDown') return value - increment
  if (key === 'ArrowRight') return value + (rtl ? -increment : increment)
  if (key === 'ArrowLeft') return value + (rtl ? increment : -increment)
  return undefined
}
css
@layer base {
  [data-slot='slider']:dir(rtl) {
    --slider-direction: -1;
  }
  .klean-slider-input {
    pointer-events: none;
  }
  .klean-slider-input::-webkit-slider-runnable-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-moz-range-track {
    height: 6px;
    background: transparent;
  }
  .klean-slider-input::-webkit-slider-thumb {
    appearance: none;
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin-top: calc((6px - var(--thumb-size)) / 2);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input::-moz-range-thumb {
    box-sizing: border-box;
    width: var(--thumb-size);
    height: var(--thumb-size);
    border: 1px solid #d1d5db;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px #0002;
    pointer-events: auto;
  }
  .klean-slider-input:focus-visible::-webkit-slider-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  .klean-slider-input:focus-visible::-moz-range-thumb {
    outline: 3px solid currentColor;
    outline-offset: 3px;
  }
  @media (forced-colors: active) {
    [data-slot='slider-track'] {
      background: GrayText;
    }
    [data-slot='slider-fill'] {
      background: Highlight;
    }
    .klean-slider-input::-webkit-slider-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
    .klean-slider-input::-moz-range-thumb {
      border-color: ButtonText;
      background: ButtonFace;
    }
  }
}
  • Input — exact numeric entry alongside relative adjustment.
  • Filter Bar — compose a range into shareable filters.
  • Select — choose discrete options that do not form an ordered numeric scale.
  • Slide — confirm an action rather than choose a value.

All open source projects are released under the MIT License.