Slider
Select a value from a continuous range. Supports single thumb for simple selection or dual thumbs for range selection with keyboard accessibility.
Single value
Choose one value from a range. Common uses include volume control, brightness adjustment, or rating input.
50
0100
70
0100
Range selection
Select a minimum and maximum value. Useful for price filters, date ranges, or any bounded selection.
25 – 75
0500
Anatomy
States
| State | Appearance | Notes |
|---|---|---|
| Default | Resting state | |
| Hover | Cursor over thumb | |
| Active | Dragging thumb | |
| Disabled | Not interactive |
Usage guidelines
✓ Do
- ·Use sliders for continuous values, not discrete choices
- ·Provide clear min/max labels to define the range
- ·Default to a meaningful middle value, not extremes
- ·Allow precise input via keyboard arrows and Shift for larger steps
✗ Don't
- ·Don't use sliders for binary choices (use a switch instead)
- ·Avoid sliders when precision is critical (use numeric input)
- ·Don't hide the current value — always show it
- ·Don't use more than 2 thumbs for range selection
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value* | number | number[] | — | Current slider value(s). Single number for single thumb, array for range. |
onValueChange* | (value: number | number[]) => void | — | Callback fired when value changes. |
min | number | 0 | Minimum value. |
max | number | 100 | Maximum value. |
step | number | 1 | Step increment. |
disabled | boolean | false | Prevents interaction and reduces opacity. |
label | string | — | Label text displayed above the slider. |
showValue | boolean | false | Display current value next to the label. |
Implementation
Slider.tsx
tsx
"use client";
import { cn } from "@/lib";
import { useState } from "react";
export interface SliderProps {
value?: number | number[];
defaultValue?: number | number[];
onValueChange?: (value: number | number[]) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
label?: string;
showValue?: boolean;
}
export function Slider({
value: controlledValue,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
label,
showValue = false,
}: SliderProps) {
const [internalValue, setInternalValue] = useState<number | number[]>(
controlledValue ?? defaultValue ?? 0
);
const currentValue = controlledValue ?? internalValue;
function handleChange(newValue: number | number[]) {
if (disabled) return;
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
}
function getPercentage(val: number) {
return ((val - min) / (max - min)) * 100;
}
const isRange = Array.isArray(currentValue);
const values = isRange ? currentValue : [currentValue];
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
return (
<div className="w-full space-y-2">
{(label || showValue) && (
<div className="flex items-center justify-between">
{label && (
<label className="text-[12px] font-medium text-[rgb(var(--text-secondary))]">
{label}
</label>
)}
{showValue && (
<span className="text-[12px] font-medium text-[rgb(var(--text-primary))]">
{isRange ? minVal + " – " + maxVal : minVal}
</span>
)}
</div>
)}
<div className="relative h-2 flex items-center">
{/* Track */}
<div className="absolute h-[3px] rounded-full w-full bg-[rgb(var(--border))]" />
{/* Fill */}
<div
className="absolute h-[3px] rounded-full bg-[rgb(var(--accent))]"
style={{
left: getPercentage(minVal) + "%",
width: getPercentage(maxVal) - getPercentage(minVal) + "%",
}}
/>
{/* Thumbs */}
{values.map((val, i) => (
<div
key={i}
className={cn(
"absolute w-4 h-4 rounded-full border-2 cursor-pointer",
"bg-white border-[rgb(var(--accent))]",
"transition-transform hover:scale-110",
disabled && "opacity-40 cursor-not-allowed"
)}
style={{
left: getPercentage(val) + "%",
transform: "translateX(-50%)",
top: "50%",
marginTop: "-6px",
}}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={val}
aria-label={label}
tabIndex={disabled ? undefined : 0}
onKeyDown={(e) => {
if (disabled) return;
const delta = e.shiftKey ? step * 10 : step;
let newVal = val;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newVal = Math.max(val - delta, min);
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newVal = Math.min(val + delta, max);
}
if (newVal !== val) {
e.preventDefault();
const newValue = isRange
? (values.map((v, idx) => (idx === i ? newVal : v)) as [number, number])
: newVal;
handleChange(newValue);
}
}}
>
<input
type="range"
min={min}
max={max}
step={step}
value={val}
onChange={(e) => {
const newVal = parseFloat(e.target.value);
if (isRange) {
const newValue = [...values] as [number, number];
newValue[i] = newVal;
handleChange(newValue);
} else {
handleChange(newVal);
}
}}
className="sr-only"
disabled={disabled}
/>
</div>
))}
</div>
<div className="flex justify-between text-[10px] text-[rgb(var(--text-tertiary))]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}Accessibility
- →Sliders must have an associated label via aria-label or aria-labelledby.
- →Keyboard: Arrow keys adjust by step; Shift + arrow keys adjust by 10× step.
- →Range sliders must have aria-valuemin, aria-valuemax, and aria-valuenow on each thumb.
- →Provide visible focus indicators on thumbs for keyboard users.
- →Value display must be screen-reader accessible via aria-valuetext if needed.