wui
组件

滑动输入 Slider

允许用户在预定义数值区间内通过拖动滑块或键盘快捷键进行连续或离散调整的交互控件。

第三方依赖 · radix-ui第三方依赖 · class-variance-authority

基础用法

最常用的单滑块用法。拖动滑块或点击轨道即可快速调整数值:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/slider
安装基础依赖
pnpm add radix-ui class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/slider.tsx
components/ui/slider.tsx
"use client"

import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

const sliderTrackVariants = cva(
  "relative w-full grow cursor-pointer overflow-hidden rounded-full bg-muted transition-[height,box-shadow] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] group-focus-within:ring-1 group-focus-within:ring-primary group-focus-within:ring-offset-1 group-focus-within:ring-offset-background motion-reduce:transition-none",
  {
    variants: {
      variant: {
        default: "h-3 group-hover:h-3.5 group-focus-within:h-3.5",
        expand: "h-2 group-hover:h-3 group-focus-within:h-3",
      },
    },
    defaultVariants: { variant: "default" },
  }
)

export interface SliderProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {
  /** Track expansion treatment used on hover and focus. @default "default" */
  variant?: "default" | "expand"
  /** Controls when the value label is visible. @default "hover" */
  showValue?: "hover" | "always" | "never"
  /** Formats the value displayed above each thumb. */
  formatValue?: (value: number) => React.ReactNode
  /** Values rendered as subtle dots on the track. */
  marks?: number[]
}

/**
 * An adjustable range input with a filled track and compact thumb. Clicking the
 * track or stepping with the keyboard glides to the new value; dragging follows
 * the pointer 1:1.
 */
function Slider({
  className,
  value,
  defaultValue = [50],
  onValueChange,
  min = 0,
  max = 100,
  variant = "default",
  showValue = "hover",
  formatValue = (current) => current,
  marks = [],
  disabled,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onLostPointerCapture,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  ...props
}: SliderProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const [dragging, setDragging] = React.useState(false)
  const pointerDownRef = React.useRef(false)
  const currentValues = value ?? internalValue

  function handleValueChange(next: number[]) {
    if (value === undefined) setInternalValue(next)
    onValueChange?.(next)
  }

  function endPointer() {
    pointerDownRef.current = false
    setDragging(false)
  }

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      data-dragging={dragging || undefined}
      className={cn(
        "group relative flex w-full touch-none select-none items-center py-3 outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        // Glide between values on click and keyboard; follow the pointer exactly while dragging.
        // Radix positions each thumb through an unstyled wrapper span, so it is targeted from the root.
        "[&>span:has([data-slot=slider-thumb])]:transition-[left] [&>span:has([data-slot=slider-thumb])]:duration-200 [&>span:has([data-slot=slider-thumb])]:ease-[cubic-bezier(0.22,1,0.36,1)] data-[dragging]:[&>span:has([data-slot=slider-thumb])]:transition-none motion-reduce:[&>span:has([data-slot=slider-thumb])]:transition-none",
        className
      )}
      value={value}
      defaultValue={defaultValue}
      onValueChange={handleValueChange}
      min={min}
      max={max}
      disabled={disabled}
      onPointerDown={(event) => {
        pointerDownRef.current = true
        onPointerDown?.(event)
      }}
      onPointerMove={(event) => {
        if (pointerDownRef.current && !dragging) setDragging(true)
        onPointerMove?.(event)
      }}
      onPointerUp={(event) => {
        endPointer()
        onPointerUp?.(event)
      }}
      onLostPointerCapture={(event) => {
        endPointer()
        onLostPointerCapture?.(event)
      }}
      {...props}
    >
      <SliderPrimitive.Track
        data-slot="slider-track"
        data-variant={variant}
        className={sliderTrackVariants({ variant })}
      >
        <SliderPrimitive.Range
          data-slot="slider-range"
          className="absolute h-full rounded-l-full bg-primary transition-[left,right] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[dragging]:transition-none motion-reduce:transition-none"
        />
        {marks.map((mark) => (
          <span
            key={mark}
            data-slot="slider-mark"
            aria-hidden="true"
            className="pointer-events-none absolute top-1/2 z-10 size-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/10"
            style={{ left: `${((mark - min) / (max - min)) * 100}%` }}
          />
        ))}
      </SliderPrimitive.Track>
      {currentValues.map((current, index) => (
        <SliderPrimitive.Thumb
          key={index}
          data-slot="slider-thumb"
          // Radix reads the accessible name from each thumb (role="slider"), not the root.
          aria-label={
            ariaLabel && currentValues.length > 1
              ? `${ariaLabel}(${index === 0 ? "最小值" : "最大值"})`
              : ariaLabel
          }
          aria-labelledby={ariaLabelledBy}
          className="group/thumb relative block size-3 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm outline-none transition-[width,height,box-shadow] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover:size-3.5 group-focus-within:size-3.5 active:size-4 active:cursor-grabbing active:ring-4 active:ring-primary/15 disabled:pointer-events-none motion-reduce:transition-none"
        >
          {showValue !== "never" ? (
            <span
              data-slot="slider-value"
              className={cn(
                "pointer-events-none absolute bottom-[calc(100%+12px)] left-1/2 origin-bottom -translate-x-1/2 whitespace-nowrap rounded-full bg-foreground px-2 py-1.5 text-[11px] font-medium leading-none tabular-nums text-background shadow-sm transition-[opacity,translate,scale] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] after:absolute after:left-1/2 after:top-[calc(100%-3px)] after:size-2 after:-translate-x-1/2 after:rotate-45 after:bg-foreground motion-reduce:transition-none",
                showValue === "always"
                  ? "opacity-100"
                  : "translate-y-1.5 scale-75 opacity-0 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:scale-100 group-focus-within:opacity-100"
              )}
            >
              {formatValue(current)}
            </span>
          ) : null}
        </SliderPrimitive.Thumb>
      ))}
    </SliderPrimitive.Root>
  )
}

export { Slider, sliderTrackVariants }

属性 Props

Slider 继承 Radix UI Slider.Root 的全部属性,支持以下配置:

属性类型默认值说明
valuenumber[]—受控模式下的数值数组。传入单个元素如 [50] 表示单滑块,传入两个元素如 [20, 80] 表示双滑块范围选择。
defaultValuenumber[][50]非受控模式下的初始数值数组。
onValueChange(value: number[]) => void—拖动或调整数值过程中连续触发的回调函数,适合实时驱动界面渲染。
onValueCommit(value: number[]) => void—用户松开鼠标、触控结束或完成键盘按键后触发的回调,适合提交持久化数据或调用远程 API。
minnumber0允许选取的最小值。
maxnumber100允许选取的最大值。
stepnumber1滑块每次移动的最小步长步进值。
minStepsBetweenThumbsnumber0在多滑块模式下,滑块之间必须保留的最小步数距离。
orientation"horizontal" | "vertical""horizontal"滑块轨道的排版方向。
variant"default" | "expand""default"轨道的交互动画变体。"expand" 会在悬停或聚焦时平滑加粗轨道高度。
showValue"hover" | "always" | "never""hover"滑块上方数值气泡提示的展示时机。
formatValue(value: number) => React.ReactNode(value) => value格式化数值气泡内容的函数,常用于添加百分号、货币符号或温度单位。
marksnumber[][]在轨道上渲染离散刻度圆点的数值数组。
disabledbooleanfalse是否禁用滑块交互。
invertedbooleanfalse是否反转轨道的起始与结束方向。
namestring—原生表单提交时该滑块对应的字段名称。
classNamestring—应用于滑块根容器的额外 CSS 类名。

事件 Events

属性类型默认值说明
onValueChange(value: number[]) => void—拖拽滑块或按下方向键时高频连续触发,返回最新的数值数组。
onValueCommit(value: number[]) => void—用户释放指针拖拽或停止键盘操作时触发,适合防抖保存或发起网络请求。
onFocus(event: React.FocusEvent<HTMLSpanElement>) => void—滑块抓手获得键盘焦点时触发。
onBlur(event: React.FocusEvent<HTMLSpanElement>) => void—滑块抓手失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLSpanElement>) => void—在滑块聚焦状态下按下键盘按键时触发。

使用场景与设计规范

Slider 适用于用户需要在一段连续或离散的数值区间内进行快速试探与相对调整的场景(例如调节音频音量、画布缩放比例、屏幕亮度、筛选价格预算等)。

数值录入控件选型对比

控件类型核心优势精度要求典型适用场景
Slider视觉化直观、拖拽调整手感流畅、即时试探中 ~ 低精度(支持模糊调整)音量、亮度、透明度、价格区间筛选
NumberInput精准数字键盘录入、微调步进按钮高精度(要求绝对准确)商品订购数量、转账金额、库存盘点
Slider + Input 联动兼顾大范围快速滑动与微观精确数值输入全精度适配图像编辑(模糊半径/饱和度)、专业参数调谐

设计最佳实践

  • 即时响应视觉变化:使用 onValueChange 即时更新关联的 UI 效果(如预览框圆角、音量图标状态);但涉及远端接口保存时,应使用 onValueCommit 避免发起海量高频请求。
  • 提供明确的极值与当前值:确保用户了解滑块的取值边界(如最小值与最大值);结合 showValue 或在外部放置数值标签,保证数值透明可读。
  • 步长 (Step) 设置合理性:根据业务精度设置 step。如果是大额价格区间可设为 10 或 50;如果是缩放系数可设为 0.1 或 0.01。
  • 无障碍文案命名:为滑块 thumb 提供明确的 aria-label(如 aria-label="音量调节"),让屏幕阅读器准确获知该控件的具体用途。

场景示例

受控模式与快捷预设

使用 value 和 onValueChange 实现受控管理。点击预设档位、点击轨道或使用方向键时,抓手与填充会平滑滑向新值;拖动时则严格跟随指针,不产生拖影:

Loading…
const [value, setValue] = React.useState([50])

return (
  <Slider
    value={value}
    onValueChange={setValue}
    min={0}
    max={100}
    step={1}
  />
)

双端范围选择 (Range)

向 value 或 defaultValue 传入两个数值即可启用双滑块区间选择,常用于电商商品价格、房源面积或时间区间的过滤筛选:

Loading…

步长与离散刻度标记 (Marks)

通过 marks 属性在轨道上标注关键档位刻度,配合 step 实现非均匀或分段式的阶梯调整(如存储容量):

Loading…

浮动数值标签 (Tooltip)

通过 showValue 属性可自由配置气泡标签的出现时机,并通过 formatValue 自定义显示的单位格式:

Loading…
  • showValue="always":始终固定展示数值气泡。
  • showValue="hover":仅在鼠标悬停或键盘聚焦在滑块抓手上时浮现。
  • showValue="never":隐藏气泡,通常在外部已有独立数值展示区域时使用。

与数字输入框联动

在专业设计器或参数调试面板中,将 Slider 与 InputNumber 联动,既支持拖拽预览,又支持键盘精确输入与长按步进:

Loading…

悬停展开变体 (Expand Variant)

设置 variant="expand" 时,轨道在默认状态下保持极细线条,当鼠标悬停或获得焦点时平滑展开变粗:

Loading…

禁用状态

设置 disabled 属性后,滑块轨道与抓手将降低透明度并拦截一切指针与键盘交互:

Loading…

无障碍与交互 Accessibility

  • ARIA 标准角色与属性:
    • 滑块抓手自动挂载 role="slider"。
    • 传给 Slider 的 aria-label / aria-labelledby 会转发到每个抓手上;范围滑块会自动追加“最小值 / 最大值”以区分两个抓手。
    • 自动同步 aria-valuemin、aria-valuemax 与当前 aria-valuenow。
    • 范围滑块在包含两个抓手时,两个抓手均具有合规的 ARIA 属性,独立朗读各自数值。
  • 键盘导航支持:
    • Tab:聚焦到滑块抓手上。
    • 方向键 → / ↑:按 step 步长递增数值。
    • 方向键 ← / ↓:按 step 步长递减数值。
    • PageUp:以大步长(通常为 10% 区间)递增数值。
    • PageDown:以大步长递减数值。
    • Home:直接跳至最小值 min。
    • End:直接跳至最大值 max。
  • 触控与移动端优化:
    • 轨道与抓手自动应用 touch-action: none,确保在移动设备上拖动滑块时不会与页面的垂直滚动产生冲突。
  • 减弱动态效果适配:
    • 浮动标签的弹出、抓手的滑动与轨道缩放动效会自动遵循 prefers-reduced-motion 设置,关闭不必要的过渡动画。