tanstack-ranger
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOverview
概述
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:
Core: (framework-agnostic)
Status: Stable
@tanstack/react-ranger@tanstack/ranger-coreTanStack Ranger 提供无头工具,用于构建完全可访问的单值范围及多范围滑块组件。它处理单值、范围和多滑块的所有复杂逻辑,同时让你完全控制样式和标记。
包:
核心: (框架无关)
状态: 稳定
@tanstack/react-ranger@tanstack/ranger-coreInstallation
安装
bash
npm install @tanstack/react-rangerbash
npm install @tanstack/react-rangerCore Pattern
核心模式
tsx
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>
)
}tsx
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%',
}}
>
{/* 轨道分段 */}
{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',
}}
/>
))}
{/* 滑块手柄 */}
{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
Ranger 配置项
Required
必填项
| Option | Type | Description |
|---|---|---|
| | Returns the slider track element |
| | Current thumb values |
| | Minimum value |
| | Maximum value |
| | Called when values change |
| 配置项 | 类型 | 描述 |
|---|---|---|
| | 返回滑块轨道元素 |
| | 当前滑块手柄的值 |
| | 最小值 |
| | 最大值 |
| | 值变化时触发 |
Optional
可选项
| Option | Type | Default | Description |
|---|---|---|---|
| | | Step increment between values |
| | - | Custom step positions (overrides stepSize) |
| | - | Size of tick marks |
| | - | Custom tick positions |
| | linear | Value interpolation function |
| | - | Called during drag operations |
| 配置项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| | | 值之间的步长增量 |
| | - | 自定义步长位置(覆盖stepSize) |
| | - | 刻度标记的大小 |
| | - | 自定义刻度位置 |
| | linear | 值插值函数 |
| | - | 拖拽操作期间触发 |
Ranger Instance API
Ranger 实例API
typescript
// Get sorted values (always ascending order)
rangerInstance.sortedValues: number[]
// Get handles for rendering thumbs
rangerInstance.handles: Handle[]
// Get track segments between handles
rangerInstance.getSteps(): { left: number; width: number }[]
// Get tick marks
rangerInstance.getTicks(): { value: number; percentage: number }[]
// Programmatically set values
rangerInstance.setValues(newValues: number[])typescript
// 获取排序后的值(始终升序)
rangerInstance.sortedValues: number[]
// 获取用于渲染滑块手柄的对象
rangerInstance.handles: Handle[]
// 获取滑块手柄之间的轨道分段
rangerInstance.getSteps(): { left: number; width: number }[]
// 获取刻度标记
rangerInstance.getTicks(): { value: number; percentage: number }[]
// 以编程方式设置值
rangerInstance.setValues(newValues: number[])Handle API
手柄API
typescript
interface Handle {
// Get percentage position on track (0-100)
getPercentage(): number
// Get the current value
getValue(): number
// Get props to spread on handle element
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
}
}typescript
interface Handle {
// 获取在轨道上的百分比位置(0-100)
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
单值滑块
tsx
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>
)
}tsx
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
多范围滑块
tsx
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>
)
}tsx
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
自定义步长
tsx
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 0,
max: 100,
steps: [0, 10, 25, 50, 75, 100], // Only these values allowed
onChange: (instance) => setValues(instance.sortedValues),
})tsx
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
刻度标记
tsx
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>
)
}tsx
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">
{/* 滑块手柄 */}
</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
对数刻度
tsx
import { logarithmicInterpolator } from '@tanstack/react-ranger'
const rangerInstance = useRanger({
getRangerElement: () => rangerRef.current,
values,
min: 1,
max: 1000,
interpolator: logarithmicInterpolator,
onChange: (instance) => setValues(instance.sortedValues),
})tsx
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:
- on handles
role="slider" - ,
aria-valuemin,aria-valuemaxattributesaria-valuenow - Keyboard navigation (Arrow keys, Home, End, Page Up/Down)
- Focus management
tsx
// Add aria-label for screen readers
<button
{...handle.getHandleProps()}
aria-label={`Value: ${handle.getValue()}`}
/>TanStack Ranger 内置可访问性功能:
- 手柄上的属性
role="slider" - 、
aria-valuemin、aria-valuemax属性aria-valuenow - 键盘导航(方向键、Home、End、Page Up/Down)
- 焦点管理
tsx
// 为屏幕阅读器添加aria-label
<button
{...handle.getHandleProps()}
aria-label={`值:${handle.getValue()}`}
/>Controlled vs Uncontrolled
受控与非受控模式
tsx
// Controlled (recommended)
const [values, setValues] = useState([50])
const ranger = useRanger({
values,
onChange: (instance) => setValues(instance.sortedValues),
// ...
})
// With validation
const handleChange = (instance) => {
const [min, max] = instance.sortedValues
// Ensure minimum gap of 10
if (max - min >= 10) {
setValues(instance.sortedValues)
}
}tsx
// 受控模式(推荐)
const [values, setValues] = useState([50])
const ranger = useRanger({
values,
onChange: (instance) => setValues(instance.sortedValues),
// ...
})
// 带验证的模式
const handleChange = (instance) => {
const [min, max] = instance.sortedValues
// 确保最小间隔为10
if (max - min >= 10) {
setValues(instance.sortedValues)
}
}Styling Tips
样式技巧
css
/* Track */
.slider-track {
position: relative;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
width: 100%;
}
/* Active segment */
.segment.active {
background: #3b82f6;
}
/* Thumb */
.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);
}css
/* 轨道 */
.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 | | Stable |
| Vue | | Stable |
| Solid | | Stable |
| Svelte | | Stable |
| Angular | | Stable |
| Core | | Stable |
| 框架 | 包 | 状态 |
|---|---|---|
| React | | 稳定 |
| Vue | | 稳定 |
| Solid | | 稳定 |
| Svelte | | 稳定 |
| Angular | | 稳定 |
| 核心 | | 稳定 |
Best Practices
最佳实践
- Always use from onChange - handles may cross during drag
sortedValues - Memoize callback to prevent unnecessary re-renders
getRangerElement - Use semantic HTML - render handles as elements for accessibility
<button> - Add to describe each handle's purpose
aria-label - Use CSS transforms () for positioning instead of
translateXfor better performanceleft - Validate in onChange to enforce constraints (min gap, max range, etc.)
- Use for real-time feedback during drag operations
onDrag - Consider touch targets - make handles at least 44x44px on mobile
- 始终使用中的
onChange- 拖拽时手柄可能交叉sortedValues - 对回调进行记忆化 以避免不必要的重渲染
getRangerElement - 使用语义化HTML - 将手柄渲染为元素以保证可访问性
<button> - 添加来描述每个手柄的用途
aria-label - 使用CSS变换()进行定位,而非
translateX,以获得更好的性能left - 在中进行验证 以强制约束(最小间隔、最大范围等)
onChange - 使用在拖拽操作期间提供实时反馈
onDrag - 考虑触摸目标 - 在移动端将手柄设置为至少44x44px
Common Pitfalls
常见误区
- Forgetting on the track container
position: relative - Using instead of
values(handles can swap positions)sortedValues - Not providing as a callback
getRangerElement - Setting thumb position with instead of
lefttransform: translateX() - Forgetting to handle keyboard navigation (built-in via getHandleProps)
- Not accounting for thumb width when calculating positions
- 忘记在轨道容器上设置
position: relative - 使用而非
values(手柄可能交换位置)sortedValues - 未将作为回调提供
getRangerElement - 使用而非
left设置手柄位置transform: translateX() - 忘记处理键盘导航(通过getHandleProps内置支持)
- 计算位置时未考虑手柄宽度