Slider
A range input for selecting numeric values along a track, providing a visual and interactive way to choose a value within a defined min/max boundary.
Use Cases
- Adjust volume, brightness, or opacity in media and design tools
- Set price range filters on e-commerce product listings
- Control zoom level on maps or image viewers
- Select a duration or timeout value in settings panels
- Choose font size or spacing in a theme customizer
- Filter search results by distance or rating
- Set progress thresholds for dashboards and reports
Simplest Implementation
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
}
export function Slider({ value, onChange, min = 0, max = 100 }: SliderProps) {
return (
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
);
}A minimal range slider using the native <input type="range">. The appearance-none class removes the default browser styling, and accent-blue-600 colors the thumb and filled portion of the track in supported browsers. The onChange callback converts the string value to a number before passing it to the parent.
Variations
With Min/Max Labels
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label?: string;
}
export function Slider({ value, onChange, min = 0, max = 100, label }: SliderProps) {
return (
<div className="w-full">
{label && (
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}The min and max values are displayed below the slider track using flex justify-between. This gives the user clear context about the range boundaries without needing to guess.
With Step Marks
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}
export function Slider({ value, onChange, min = 0, max = 100, step = 25 }: SliderProps) {
const marks = [];
for (let i = min; i <= max; i += step) {
marks.push(i);
}
return (
<div className="w-full">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
<div className="relative mt-2 h-4">
{marks.map((mark) => {
const percent = ((mark - min) / (max - min)) * 100;
return (
<span
key={mark}
className="absolute -translate-x-1/2 text-xs text-gray-500"
style={{ left: `${percent}%` }}
>
{mark}
</span>
);
})}
</div>
</div>
);
}Step marks are generated from the step prop and positioned absolutely using a percentage offset. Each mark is centered over its position with -translate-x-1/2. The step attribute on the input ensures the thumb snaps to these discrete values.
Dual-Thumb Range Slider
"use client";
import { useState, useCallback } from "react";
interface RangeSliderProps {
min?: number;
max?: number;
values: [number, number];
onChange: (values: [number, number]) => void;
}
export function RangeSlider({ min = 0, max = 100, values, onChange }: RangeSliderProps) {
const [dragging, setDragging] = useState<"min" | "max" | null>(null);
const getPercent = useCallback(
(val: number) => ((val - min) / (max - min)) * 100,
[min, max]
);
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMin = Math.min(Number(e.target.value), values[1] - 1);
onChange([newMin, values[1]]);
};
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMax = Math.max(Number(e.target.value), values[0] + 1);
onChange([values[0], newMax]);
};
return (
<div className="relative w-full">
{/* Track background */}
<div className="h-2 w-full rounded-full bg-gray-200" />
{/* Active range */}
<div
className="absolute top-0 h-2 rounded-full bg-blue-600"
style={{
left: `${getPercent(values[0])}%`,
width: `${getPercent(values[1]) - getPercent(values[0])}%`,
}}
/>
{/* Min thumb */}
<input
type="range"
min={min}
max={max}
value={values[0]}
onChange={handleMinChange}
onMouseDown={() => setDragging("min")}
onMouseUp={() => setDragging(null)}
className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
style={{ zIndex: dragging === "min" ? 30 : 20 }}
/>
{/* Max thumb */}
<input
type="range"
min={min}
max={max}
value={values[1]}
onChange={handleMaxChange}
onMouseDown={() => setDragging("max")}
onMouseUp={() => setDragging(null)}
className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
style={{ zIndex: dragging === "max" ? 30 : 20 }}
/>
{/* Value labels */}
<div className="mt-4 flex justify-between text-sm text-gray-600">
<span>{values[0]}</span>
<span>{values[1]}</span>
</div>
</div>
);
}Two overlapping range inputs create a dual-thumb slider. The inputs are set to pointer-events-none with only the thumbs receiving pointer-events-auto, so the user can drag each independently. The active track segment between the thumbs is an absolutely positioned div with dynamic left and width values. The z-index is adjusted based on which thumb is being dragged to prevent the wrong thumb from capturing the event when they overlap.
With Live Value Display
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label: string;
unit?: string;
}
export function Slider({ value, onChange, min = 0, max = 100, label, unit = "" }: SliderProps) {
return (
<div className="w-full">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700">
{value}{unit}
</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
</div>
);
}The current value is displayed as a badge in the top-right corner, updating in real-time as the user drags the thumb. The unit prop (e.g., "px", "%", "ms") appends a unit suffix to the displayed value for clarity.
Vertical Slider
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
height?: string;
}
export function VerticalSlider({ value, onChange, min = 0, max = 100, height = "12rem" }: SliderProps) {
return (
<div className="flex flex-col items-center gap-2" style={{ height }}>
<span className="text-xs text-gray-500">{max}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
style={{
writingMode: "vertical-lr",
direction: "rtl",
width: height,
}}
/>
<span className="text-xs text-gray-500">{min}</span>
</div>
);
}The writing-mode: vertical-lr and direction: rtl CSS properties rotate the native range input to a vertical orientation. The direction: rtl ensures the minimum value is at the bottom and maximum at the top. This approach avoids CSS transforms which can break click coordinates.
Complex Implementation
"use client";
import { forwardRef, useId, useCallback, useRef, useEffect, useState } from "react";
type SliderSize = "sm" | "md" | "lg";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
size?: SliderSize;
label?: string;
showValue?: boolean;
showTooltip?: boolean;
unit?: string;
disabled?: boolean;
marks?: { value: number; label?: string }[];
name?: string;
id?: string;
className?: string;
}
const sizeClasses: Record<SliderSize, { track: string; thumb: string }> = {
sm: {
track: "h-1",
thumb: "[&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5",
},
md: {
track: "h-2",
thumb: "[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5",
},
lg: {
track: "h-3",
thumb: "[&::-webkit-slider-thumb]:h-6 [&::-webkit-slider-thumb]:w-6",
},
};
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
{
value,
onChange,
min = 0,
max = 100,
step = 1,
size = "md",
label,
showValue = false,
showTooltip = false,
unit = "",
disabled = false,
marks,
name,
id: externalId,
className,
},
ref
) {
const generatedId = useId();
const sliderId = externalId ?? generatedId;
const s = sizeClasses[size];
const [isDragging, setIsDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const percent = ((value - min) / (max - min)) * 100;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
onChange(Number(e.target.value));
},
[disabled, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
let newValue = value;
switch (e.key) {
case "Home":
newValue = min;
break;
case "End":
newValue = max;
break;
default:
return;
}
e.preventDefault();
onChange(newValue);
},
[disabled, value, min, max, onChange]
);
return (
<div className={`w-full ${disabled ? "opacity-50" : ""} ${className ?? ""}`}>
{(label || showValue) && (
<div className="mb-2 flex items-center justify-between">
{label && (
<label htmlFor={sliderId} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
{showValue && (
<output
htmlFor={sliderId}
className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700"
>
{value}{unit}
</output>
)}
</div>
)}
<div ref={trackRef} className="relative">
{/* Custom track fill */}
<div
className={`pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-blue-600 ${s.track}`}
style={{ width: `${percent}%` }}
/>
{/* Tooltip */}
{showTooltip && isDragging && (
<div
className="absolute -top-8 z-10 -translate-x-1/2 rounded bg-gray-900 px-2 py-0.5 text-xs text-white"
style={{ left: `${percent}%` }}
>
{value}{unit}
</div>
)}
<input
ref={ref}
id={sliderId}
type="range"
name={name}
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onTouchStart={() => setIsDragging(true)}
onTouchEnd={() => setIsDragging(false)}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value}${unit}`}
className={[
"relative w-full cursor-pointer appearance-none rounded-full bg-gray-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600 [&::-webkit-slider-thumb]:transition-shadow",
"[&::-webkit-slider-thumb]:hover:shadow-lg [&::-webkit-slider-thumb]:active:ring-blue-700",
disabled ? "cursor-not-allowed" : "",
s.track,
s.thumb,
].join(" ")}
/>
</div>
{marks && marks.length > 0 && (
<div className="relative mt-2 h-5">
{marks.map((mark) => {
const markPercent = ((mark.value - min) / (max - min)) * 100;
return (
<button
key={mark.value}
type="button"
onClick={() => !disabled && onChange(mark.value)}
className="absolute -translate-x-1/2 text-xs text-gray-500 hover:text-gray-700"
style={{ left: `${markPercent}%` }}
>
{mark.label ?? mark.value}
</button>
);
})}
</div>
)}
</div>
);
});Key aspects:
- forwardRef -- allows parent components to access the native input for focus management or integration with form libraries.
- Custom track fill -- an absolutely positioned div covers the left portion of the track proportional to the current value, providing a filled-track visual that works across browsers without vendor-specific pseudo-element hacks.
- Drag-only tooltip -- the tooltip appears only while the user is actively dragging (
isDraggingstate), preventing clutter while providing precise value feedback during interaction. - ARIA value attributes --
aria-valuenow,aria-valuemin,aria-valuemax, andaria-valuetextgive screen readers full context about the slider's current state. Thearia-valuetextincludes the unit for human-readable announcements. - Home/End key support -- the keyboard handler adds Home (jump to min) and End (jump to max) on top of the browser's native arrow key handling, matching the WAI-ARIA slider pattern.
- Clickable marks -- mark labels are rendered as buttons that snap the slider to their value on click, providing quick access to common presets without precise dragging.
<output>element -- the live value display uses the semantic<output>element withhtmlForlinking it to the slider, which screen readers treat as a live region.- Touch support --
onTouchStartandonTouchEndlisteners track dragging state on mobile in addition to mouse events.
Gotchas
-
onChangereturns a string -- the native range input'sonChangeevent givese.target.valueas a string. Always convert withNumber()orparseFloat()before using the value in calculations. -
Cross-browser thumb styling -- WebKit, Firefox, and Edge all use different pseudo-elements for the slider thumb (
::-webkit-slider-thumb,::-moz-range-thumb). Tailwind's arbitrary selectors only target WebKit by default. Add Firefox-specific styles if needed. -
Step floating-point precision -- a
stepof0.1can produce values like0.30000000000000004due to floating-point math. Round displayed values withtoFixed()and clamp stored values to the desired precision. -
Vertical orientation is non-standard -- the
writing-modeCSS trick for vertical sliders does not work consistently across all browsers. Consider a fully custom implementation with mouse/touch event handlers for reliable vertical behavior. -
Dual-thumb z-index conflicts -- when two range inputs overlap for a range slider, the top input captures all events. Use
pointer-events-noneon the input track andpointer-events-autoon the thumb only, plus dynamicz-indexon the active thumb. -
No
onChangeon programmatic value change -- setting the value via state does not trigger the nativechangeevent. If you need to sync with external listeners, dispatch a synthetic event after updating. -
Disabled styling on native input -- the native range input's disabled appearance varies wildly across browsers. Apply your own
opacityandcursor-not-allowedclasses on the wrapper for a consistent look.