| name | tanstack-ranger |
| description | Headless utilities for building range and multi-range sliders in TS/JS, React, Vue, Solid, Svelte & Angular. |
Overview
TanStack Ranger provides headless utilities for building fully accessible range and multi-range slider components. It handles all the complex logic for single value, range, and multi-thumb sliders while giving you complete control over styling and markup.
Package: @tanstack/react-ranger
Core: @tanstack/ranger-core (framework-agnostic)
Status: Stable
Installation
npm install @tanstack/react-ranger
Core Pattern
import { useRanger } from "@tanstack/react-ranger";
function RangeSlider() {
const [values, setValues] = useState([25, 75]);
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
stepSize: 1,
onChange: (instance) => setValues(instance.sortedValues),
});
const rangerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={rangerRef}
style={{
position: "relative",
height: "8px",
background: "#ddd",
borderRadius: "4px",
width: "100%",
}}
>
{/* Track segments */}
{rangerInstance.getSteps().map(({ left, width }, i) => (
<div
key={i}
style={{
position: "absolute",
left: `${left}%`,
width: `${width}%`,
height: "100%",
background: i === 1 ? "#3b82f6" : "#ddd",
borderRadius: "4px",
}}
/>
))}
{/* Thumbs */}
{rangerInstance.handles.map((handle, i) => (
<button
key={i}
{...handle.getHandleProps()}
style={{
position: "absolute",
left: `${handle.getPercentage()}%`,
transform: "translateX(-50%)",
width: "20px",
height: "20px",
borderRadius: "50%",
background: "#3b82f6",
border: "2px solid white",
cursor: "grab",
}}
/>
))}
</div>
);
}
Ranger Options
Required
| Option | Type | Description |
|---|
getRangerElement | () => Element | null | Returns the slider track element |
values | number[] | Current thumb values |
min | number | Minimum value |
max | number | Maximum value |
onChange | (instance) => void | Called when values change |
Optional
| Option | Type | Default | Description |
|---|
stepSize | number | 1 | Step increment between values |
steps | number[] | - | Custom step positions (overrides stepSize) |
tickSize | number | - | Size of tick marks |
ticks | number[] | - | Custom tick positions |
interpolator | Interpolator | linear | Value interpolation function |
onDrag | (instance) => void | - | Called during drag operations |
Ranger Instance API
rangerInstance.sortedValues: number[]
rangerInstance.handles: Handle[]
rangerInstance.getSteps(): { left: number; width: number }[]
rangerInstance.getTicks(): { value: number; percentage: number }[]
rangerInstance.setValues(newValues: number[])
Handle API
interface Handle {
getPercentage(): number;
getValue(): number;
getHandleProps(): {
role: "slider";
tabIndex: number;
"aria-valuemin": number;
"aria-valuemax": number;
"aria-valuenow": number;
onKeyDown: (e: KeyboardEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
};
}
Single Value Slider
function SingleSlider() {
const [values, setValues] = useState([50]);
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
stepSize: 1,
onChange: (instance) => setValues(instance.sortedValues),
});
const rangerRef = useRef<HTMLDivElement>(null);
return (
<div ref={rangerRef} className="slider-track">
{rangerInstance.handles.map((handle, i) => (
<button key={i} {...handle.getHandleProps()} className="slider-thumb">
{handle.getValue()}
</button>
))}
</div>
);
}
Multi-Range Slider
function MultiRangeSlider() {
const [values, setValues] = useState([10, 40, 60, 90]);
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
stepSize: 5,
onChange: (instance) => setValues(instance.sortedValues),
});
const rangerRef = useRef<HTMLDivElement>(null);
return (
<div ref={rangerRef} className="slider-track">
{rangerInstance.getSteps().map(({ left, width }, i) => (
<div
key={i}
className={`segment ${i % 2 === 1 ? "active" : ""}`}
style={{ left: `${left}%`, width: `${width}%` }}
/>
))}
{rangerInstance.handles.map((handle, i) => (
<button key={i} {...handle.getHandleProps()} className="slider-thumb" />
))}
</div>
);
}
Custom Steps
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
steps: [0, 10, 25, 50, 75, 100],
onChange: (instance) => setValues(instance.sortedValues),
});
Tick Marks
function SliderWithTicks() {
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
stepSize: 10,
ticks: [0, 25, 50, 75, 100],
onChange: (instance) => setValues(instance.sortedValues),
});
return (
<div>
<div ref={rangerRef} className="slider-track">
{/* Handles */}
</div>
<div className="tick-container">
{rangerInstance.getTicks().map((tick, i) => (
<div key={i} style={{ left: `${tick.percentage}%` }} className="tick">
<span className="tick-label">{tick.value}</span>
</div>
))}
</div>
</div>
);
}
Logarithmic Scale
import { logarithmicInterpolator } from "@tanstack/react-ranger";
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 1,
max: 1000,
interpolator: logarithmicInterpolator,
onChange: (instance) => setValues(instance.sortedValues),
});
Accessibility
TanStack Ranger provides built-in accessibility:
role="slider" on handles
aria-valuemin, aria-valuemax, aria-valuenow attributes
- Keyboard navigation (Arrow keys, Home, End, Page Up/Down)
- Focus management
<button {...handle.getHandleProps()} aria-label={`Value: ${handle.getValue()}`} />
Controlled vs Uncontrolled
const [values, setValues] = useState([50]);
const ranger = useRanger({
values,
onChange: (instance) => setValues(instance.sortedValues),
});
const handleChange = (instance) => {
const [min, max] = instance.sortedValues;
if (max - min >= 10) {
setValues(instance.sortedValues);
}
};
Styling Tips
.slider-track {
position: relative;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
width: 100%;
}
.segment.active {
background: #3b82f6;
}
.slider-thumb {
position: absolute;
transform: translateX(-50%);
width: 20px;
height: 20px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: grab;
}
.slider-thumb:active {
cursor: grabbing;
}
.slider-thumb:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
Framework Adapters
| Framework | Package | Status |
|---|
| React | @tanstack/react-ranger | Stable |
| Vue | @tanstack/vue-ranger | Stable |
| Solid | @tanstack/solid-ranger | Stable |
| Svelte | @tanstack/svelte-ranger | Stable |
| Angular | @tanstack/angular-ranger | Stable |
| Core | @tanstack/ranger-core | Stable |
Best Practices
- Always use
sortedValues from onChange - handles may cross during drag
- Memoize
getRangerElement callback to prevent unnecessary re-renders
- Use semantic HTML - render handles as
<button> elements for accessibility
- Add
aria-label to describe each handle's purpose
- Use CSS transforms (
translateX) for positioning instead of left for better performance
- Validate in onChange to enforce constraints (min gap, max range, etc.)
- Use
onDrag for real-time feedback during drag operations
- Consider touch targets - make handles at least 44x44px on mobile
Common Pitfalls
- Forgetting
position: relative on the track container
- Using
values instead of sortedValues (handles can swap positions)
- Not providing
getRangerElement as a callback
- Setting thumb position with
left instead of transform: translateX()
- Forgetting to handle keyboard navigation (built-in via getHandleProps)
- Not accounting for thumb width when calculating positions