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.
<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>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'}
/>
</>
)
}<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
<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
npx klean-ui add sliderSingle 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.
<Slider
v-model="budget"
:max="1000"
:step="10"
aria-label="Monthly budget"
:labels="['Minimum budget', 'Maximum budget']"
:value-text="(value) => '$' + value"
:name="['minimum', 'maximum']"
/><Slider
value={budget}
onChange={setBudget}
max={1000}
step={10}
aria-label="Monthly budget"
labels={['Minimum budget', 'Maximum budget']}
valueText={(value) => '$' + value}
name={['minimum', 'maximum']}
/><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.
<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]
"
/><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]
}
/><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.
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Bind value | v-model | value + onChange | bind:value |
| Observe changes | @change | onChange | onchange |
| Completed adjustment | @commit | onCommit | oncommit |
| Classes | class | className | class |
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.
View source Slider.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:
<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
<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>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<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.
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
}@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;
}
}
}Related components
- 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.