tanstack-ranger

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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
TanStack Ranger 提供无头工具,用于构建完全可访问的单值范围及多范围滑块组件。它处理单值、范围和多滑块的所有复杂逻辑,同时让你完全控制样式和标记。
包:
@tanstack/react-ranger
核心:
@tanstack/ranger-core
(框架无关) 状态: 稳定

Installation

安装

bash
npm install @tanstack/react-ranger
bash
npm install @tanstack/react-ranger

Core 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

必填项

OptionTypeDescription
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
配置项类型描述
getRangerElement
() => Element | null
返回滑块轨道元素
values
number[]
当前滑块手柄的值
min
number
最小值
max
number
最大值
onChange
(instance) => void
值变化时触发

Optional

可选项

OptionTypeDefaultDescription
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
linearValue interpolation function
onDrag
(instance) => void
-Called during drag operations
配置项类型默认值描述
stepSize
number
1
值之间的步长增量
steps
number[]
-自定义步长位置(覆盖stepSize)
tickSize
number
-刻度标记的大小
ticks
number[]
-自定义刻度位置
interpolator
Interpolator
linear值插值函数
onDrag
(instance) => void
-拖拽操作期间触发

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:
  • role="slider"
    on handles
  • aria-valuemin
    ,
    aria-valuemax
    ,
    aria-valuenow
    attributes
  • 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

框架适配

FrameworkPackageStatus
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
框架状态
React
@tanstack/react-ranger
稳定
Vue
@tanstack/vue-ranger
稳定
Solid
@tanstack/solid-ranger
稳定
Svelte
@tanstack/svelte-ranger
稳定
Angular
@tanstack/angular-ranger
稳定
核心
@tanstack/ranger-core
稳定

Best Practices

最佳实践

  1. Always use
    sortedValues
    from onChange - handles may cross during drag
  2. Memoize
    getRangerElement
    callback to prevent unnecessary re-renders
  3. Use semantic HTML - render handles as
    <button>
    elements for accessibility
  4. Add
    aria-label
    to describe each handle's purpose
  5. Use CSS transforms (
    translateX
    ) for positioning instead of
    left
    for better performance
  6. Validate in onChange to enforce constraints (min gap, max range, etc.)
  7. Use
    onDrag
    for real-time feedback during drag operations
  8. Consider touch targets - make handles at least 44x44px on mobile
  1. 始终使用
    onChange
    中的
    sortedValues
    - 拖拽时手柄可能交叉
  2. getRangerElement
    回调进行记忆化
    以避免不必要的重渲染
  3. 使用语义化HTML - 将手柄渲染为
    <button>
    元素以保证可访问性
  4. 添加
    aria-label
    来描述每个手柄的用途
  5. 使用CSS变换
    translateX
    )进行定位,而非
    left
    ,以获得更好的性能
  6. onChange
    中进行验证
    以强制约束(最小间隔、最大范围等)
  7. 使用
    onDrag
    在拖拽操作期间提供实时反馈
  8. 考虑触摸目标 - 在移动端将手柄设置为至少44x44px

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
  • 忘记在轨道容器上设置
    position: relative
  • 使用
    values
    而非
    sortedValues
    (手柄可能交换位置)
  • 未将
    getRangerElement
    作为回调提供
  • 使用
    left
    而非
    transform: translateX()
    设置手柄位置
  • 忘记处理键盘导航(通过getHandleProps内置支持)
  • 计算位置时未考虑手柄宽度