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.
Search across all documentation pages
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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:
isDragging state), preventing clutter while providing precise value feedback during interaction.aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext give screen readers full context about the slider's current state. The aria-valuetext includes the unit for human-readable announcements.<output> element -- the live value display uses the semantic <output> element with htmlFor linking it to the slider, which screen readers treat as a live region.onTouchStart and onTouchEnd listeners track dragging state on mobile in addition to mouse events.onChange returns a string -- the native range input's onChange event gives e.target.value as a string. Always convert with Number() or parseFloat() 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 step of 0.1 can produce values like 0.30000000000000004 due to floating-point math. Round displayed values with toFixed() and clamp stored values to the desired precision.
Vertical orientation is non-standard -- the writing-mode CSS 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-none on the input track and pointer-events-auto on the thumb only, plus dynamic z-index on the active thumb.
No onChange on programmatic value change -- setting the value via state does not trigger the native change event. 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 opacity and cursor-not-allowed classes on the wrapper for a consistent look.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥