# Slider

> Pick a value or a range by dragging, tapping the track or using the keys, with jumps that glide and an optional value bubble.

Docs: https://hextaui.com/docs/slider
Markdown: https://hextaui.com/docs/slider.md

```tsx title="components/examples/slider/demo.tsx"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderDemo() {
  return (
    <Slider defaultValue={60} className="max-w-xs">
      <SliderLabel>Volume</SliderLabel>
      <SliderValue />
    </Slider>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/slider.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/slider.tsx"
"use client"

import * as React from "react"
import {
  DirectionProvider,
  useDirection,
  type TextDirection,
} from "@base-ui/react/direction-provider"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const jumpTime = 180

const sliderVariants = cva(
  "group/slider flex min-w-0 data-disabled:opacity-50",
  {
    variants: {
      size: {
        sm: "[--slider-thumb:0.875rem] [--slider-track:0.25rem]",
        default: "[--slider-thumb:1.125rem] [--slider-track:0.375rem]",
        lg: "[--slider-thumb:1.375rem] [--slider-track:0.5rem]",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

const orientationClasses = {
  horizontal: {
    root: "w-full flex-wrap items-center gap-x-3 gap-y-2",
    control:
      "h-(--slider-thumb) w-full basis-full before:inset-x-0 before:-inset-y-2 pointer-coarse:before:-inset-y-3.5",
    track: "h-(--slider-track) w-full",
    value: "bottom-[calc(100%+0.5rem)] left-1/2 -translate-x-1/2 origin-bottom",
  },
  vertical: {
    root: "h-full flex-col items-center gap-3",
    control:
      "min-h-40 w-(--slider-thumb) flex-1 flex-col justify-center before:-inset-x-2 before:inset-y-0 pointer-coarse:before:-inset-x-3.5",
    track: "h-full w-(--slider-track)",
    value:
      "start-[calc(100%+0.5rem)] top-1/2 -translate-y-1/2 origin-left rtl:origin-right",
  },
} as const

type SliderValueType = number | readonly number[]

type SliderProps<Value extends SliderValueType = SliderValueType> =
  SliderPrimitive.Root.Props<Value> &
    VariantProps<typeof sliderVariants> & {
      showValue?: boolean
      draggableRange?: boolean
      getAriaLabel?: (index: number) => string
      getAriaValueText?: (
        formattedValue: string,
        value: number,
        index: number
      ) => string
    }

type RangeDrag = {
  id: number
  origin: number
  values: readonly number[]
  scale: number
  valueAt: (position: number) => number
  started: boolean
  last: number[] | null
}

function countThumbs(value: unknown) {
  return Array.isArray(value) ? Math.max(1, Math.min(value.length, 100)) : 1
}

function sameValue(a: unknown, b: unknown) {
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((item, index) => item === b[index])
  }
  return a === b
}

function decimalsOf(value: number) {
  return Number.isFinite(value) ? (String(value).split(".")[1] ?? "").length : 0
}

function changeDetails(
  reason: "drag" | "track-press",
  event: PointerEvent,
  trigger: Element
) {
  const details = {
    reason,
    event,
    trigger,
    activeThumbIndex: -1,
    isCanceled: false,
    isPropagationAllowed: false,
    cancel() {
      details.isCanceled = true
    },
    allowPropagation() {
      details.isPropagationAllowed = true
    },
  }
  return details as SliderPrimitive.Root.ChangeEventDetails
}

function useJump(rootRef: React.RefObject<HTMLElement | null>) {
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined
  )
  const drags = React.useRef(0)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  const stop = React.useCallback(() => {
    clearTimeout(timer.current)
    rootRef.current?.removeAttribute("data-jump")
  }, [rootRef])

  const start = React.useCallback(() => {
    const root = rootRef.current
    if (!root) {
      return
    }
    clearTimeout(timer.current)
    drags.current = 0
    root.setAttribute("data-jump", "")
    timer.current = setTimeout(stop, jumpTime + 40)
  }, [rootRef, stop])

  const drag = React.useCallback(() => {
    if (!rootRef.current?.hasAttribute("data-jump")) {
      return
    }
    drags.current += 1
    if (drags.current >= 3) {
      stop()
    }
  }, [rootRef, stop])

  return { start, drag, stop }
}

function Slider<Value extends SliderValueType>({
  className,
  children,
  size = "default",
  showValue = false,
  draggableRange = false,
  value,
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  minStepsBetweenValues = 0,
  orientation = "horizontal",
  thumbAlignment = "edge",
  locale = "en-US",
  format,
  disabled,
  onValueChange,
  onValueCommitted,
  getAriaLabel,
  getAriaValueText,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  ref,
  ...props
}: SliderProps<Value>) {
  const rootRef = React.useRef<HTMLDivElement | null>(null)
  const inherited = useDirection()
  const [domDirection, setDomDirection] = React.useState<TextDirection>("ltr")
  const direction = domDirection === "rtl" ? "rtl" : inherited
  const jump = useJump(rootRef)
  const controlled = value !== undefined
  const [internal, setInternal] = React.useState<SliderValueType>(
    defaultValue ?? min
  )
  const current = controlled ? value : internal
  const emitted = React.useRef<unknown>(current)
  const previous = React.useRef<unknown>(current)
  const rangeDrag = React.useRef<RangeDrag | null>(null)
  const thumbs = countThumbs(current)
  const layout = orientationClasses[orientation]
  const vertical = orientation === "vertical"
  const rangeEnabled = draggableRange && !disabled && thumbs > 1

  const formatter = React.useMemo(() => {
    try {
      return new Intl.NumberFormat(locale, format)
    } catch {
      return new Intl.NumberFormat("en-US")
    }
  }, [locale, format])

  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node
      if (node?.isConnected) {
        setDomDirection(
          getComputedStyle(node).direction === "rtl" ? "rtl" : "ltr"
        )
      }
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const blockTouchPress = React.useCallback((control: HTMLElement | null) => {
    if (!control) {
      return undefined
    }
    const onTouchStart = (event: TouchEvent) => {
      if (rangeDrag.current) {
        event.stopImmediatePropagation()
        event.stopPropagation()
      }
    }
    control.addEventListener("touchstart", onTouchStart, {
      capture: true,
      passive: true,
    })
    return () => control.removeEventListener("touchstart", onTouchStart, true)
  }, [])

  React.useLayoutEffect(() => {
    if (sameValue(current, previous.current)) {
      return
    }
    previous.current = current
    if (!sameValue(current, emitted.current)) {
      jump.start()
    }
  }, [current, jump])

  const handleValueChange = (
    next: number | readonly number[],
    details: SliderPrimitive.Root.ChangeEventDetails
  ) => {
    onValueChange?.(next as never, details)
    if (details.isCanceled) {
      return false
    }
    emitted.current = next
    if (!controlled) {
      setInternal(next)
    }
    if (details.reason === "drag") {
      jump.drag()
    } else {
      jump.start()
    }
    return true
  }

  const axisOf = (event: { clientX: number; clientY: number }) =>
    vertical ? event.clientY : event.clientX

  const startRangeDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (
      !rangeEnabled ||
      event.button !== 0 ||
      !Array.isArray(current) ||
      !(event.target instanceof Element) ||
      event.target.closest("[data-slot=slider-thumb]")
    ) {
      return
    }
    const control = event.currentTarget
    const handles = control.querySelectorAll<HTMLElement>(
      "[data-slot=slider-thumb]"
    )
    const first = handles[0]?.getBoundingClientRect()
    const last = handles[handles.length - 1]?.getBoundingClientRect()
    if (!first || !last) {
      return
    }
    const thumbSize = vertical ? first.height : first.width
    const centers = [first, last].map((rect) =>
      vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2
    )
    const position = axisOf(event)
    if (
      position <= Math.min(...centers) + thumbSize / 2 ||
      position >= Math.max(...centers) - thumbSize / 2
    ) {
      return
    }
    const rect = control.getBoundingClientRect()
    const inset = thumbAlignment === "center" ? 0 : thumbSize
    const length = (vertical ? rect.height : rect.width) - inset
    if (length <= 0 || max <= min) {
      return
    }
    event.preventDefault()
    const sign = vertical || direction === "rtl" ? -1 : 1
    const startEdge = vertical
      ? rect.bottom
      : direction === "rtl"
        ? rect.right
        : rect.left
    control.setPointerCapture?.(event.pointerId)
    rangeDrag.current = {
      id: event.pointerId,
      origin: position,
      values: current,
      scale: ((max - min) / length) * sign,
      valueAt: (at) =>
        min +
        Math.min(
          1,
          Math.max(0, ((at - startEdge) * sign - inset / 2) / length)
        ) *
          (max - min),
      started: false,
      last: null,
    }
  }

  const moveRange = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = rangeDrag.current
    if (!state || event.pointerId !== state.id) {
      return
    }
    const distance = axisOf(event) - state.origin
    if (!state.started) {
      if (Math.abs(distance) < 3) {
        return
      }
      state.started = true
      jump.stop()
      rootRef.current?.setAttribute("data-range-dragging", "")
    }
    const values = state.values
    const precision = Math.max(decimalsOf(step), decimalsOf(min))
    const snapped = Math.round((distance * state.scale) / step) * step
    const delta = Math.min(
      max - values[values.length - 1],
      Math.max(min - values[0], snapped)
    )
    const next = values.map((item) => Number((item + delta).toFixed(precision)))
    if (sameValue(next, state.last ?? values)) {
      return
    }
    if (
      handleValueChange(
        next,
        changeDetails("drag", event.nativeEvent, event.currentTarget)
      )
    ) {
      state.last = next
    }
  }

  const endRange = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = rangeDrag.current
    if (!state || event.pointerId !== state.id) {
      return
    }
    rangeDrag.current = null
    rootRef.current?.removeAttribute("data-range-dragging")
    const control = event.currentTarget
    if (control.hasPointerCapture?.(event.pointerId)) {
      control.releasePointerCapture(event.pointerId)
    }
    if (state.started) {
      if (state.last) {
        onValueCommitted?.(state.last as never, {
          reason: "drag",
          event: event.nativeEvent,
        })
      }
      return
    }
    if (event.type !== "pointerup") {
      return
    }
    const values = state.values
    const precision = Math.max(decimalsOf(step), decimalsOf(min))
    const raw =
      min + Math.round((state.valueAt(axisOf(event)) - min) / step) * step
    let index = 0
    values.forEach((item, i) => {
      if (Math.abs(item - raw) <= Math.abs(values[index] - raw)) {
        index = i
      }
    })
    const gap = minStepsBetweenValues * step
    const floor = index > 0 ? values[index - 1] + gap : min
    const ceiling = index < values.length - 1 ? values[index + 1] - gap : max
    const target = Number(
      Math.min(ceiling, Math.max(floor, raw)).toFixed(precision)
    )
    const next = values.map((item, i) => (i === index ? target : item))
    control
      .querySelectorAll<HTMLElement>("[data-slot=slider-thumb]")
      [index]?.querySelector("input")
      ?.focus({ preventScroll: true, focusVisible: false } as FocusOptions)
    if (
      !sameValue(next, values) &&
      handleValueChange(
        next,
        changeDetails("track-press", event.nativeEvent, control)
      )
    ) {
      onValueCommitted?.(next as never, {
        reason: "track-press",
        event: event.nativeEvent,
      })
    }
  }

  return (
    <DirectionProvider direction={direction}>
      <SliderPrimitive.Root
        ref={setRef}
        value={current as Value}
        min={min}
        max={max}
        step={step}
        minStepsBetweenValues={minStepsBetweenValues}
        orientation={orientation}
        thumbAlignment={thumbAlignment}
        locale={locale}
        format={format}
        disabled={disabled}
        onValueChange={handleValueChange}
        onValueCommitted={onValueCommitted}
        data-size={size}
        className={mergeClassName(
          cn(sliderVariants({ size }), layout.root),
          className
        )}
        {...props}
        data-slot="slider"
      >
        {children}
        <SliderPrimitive.Control
          ref={rangeEnabled ? blockTouchPress : undefined}
          data-slot="slider-control"
          onPointerDown={rangeEnabled ? startRangeDrag : undefined}
          onPointerMove={rangeEnabled ? moveRange : undefined}
          onPointerUp={rangeEnabled ? endRange : undefined}
          onPointerCancel={rangeEnabled ? endRange : undefined}
          className={cn(
            "relative flex cursor-pointer touch-none items-center select-none [-webkit-tap-highlight-color:transparent] group-data-dragging/slider:cursor-grabbing group-data-range-dragging/slider:cursor-grabbing before:absolute data-disabled:cursor-not-allowed",
            layout.control
          )}
        >
          <SliderPrimitive.Track
            data-slot="slider-track"
            className={cn(
              "relative grow overflow-hidden rounded-full bg-foreground/10 select-none dark:bg-foreground/15 forced-colors:border",
              layout.track
            )}
          >
            <SliderPrimitive.Indicator
              data-slot="slider-range"
              className={cn(
                "bg-primary select-none motion-safe:group-data-jump/slider:transition-[width,height,left,right,bottom] motion-safe:group-data-jump/slider:duration-180 motion-safe:group-data-jump/slider:ease-out-quint forced-colors:bg-highlight",
                rangeEnabled &&
                  "cursor-grab group-data-range-dragging/slider:cursor-grabbing"
              )}
            />
          </SliderPrimitive.Track>
          {Array.from({ length: thumbs }, (_, index) => (
            <SliderPrimitive.Thumb
              key={index}
              index={index}
              aria-label={ariaLabel}
              aria-labelledby={ariaLabelledBy}
              getAriaLabel={getAriaLabel}
              getAriaValueText={getAriaValueText}
              data-slot="slider-thumb"
              className="group/slider-thumb relative block size-(--slider-thumb) shrink-0 cursor-grab rounded-full border border-foreground/20 bg-background bg-clip-padding transition-[box-shadow,border-color] duration-150 ease-out-quint outline-none select-none group-data-dragging/slider:cursor-grabbing group-data-range-dragging/slider:ring-6 group-data-range-dragging/slider:ring-foreground/8 after:absolute after:-inset-1.5 hover:ring-4 hover:ring-foreground/6 focus-visible:outline-hidden has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-focus-ring data-invalid:border-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 motion-safe:group-data-jump/slider:transition-[box-shadow,border-color,left,right,bottom] motion-safe:group-data-jump/slider:duration-[150ms,150ms,180ms,180ms,180ms] dark:border-background/60 dark:bg-foreground dark:group-data-range-dragging/slider:ring-foreground/15 dark:hover:ring-foreground/10 dark:data-invalid:ring-destructive/40 pointer-coarse:after:-inset-3.5 data-disabled:cursor-not-allowed data-disabled:hover:ring-0 group-data-dragging/slider:data-active:ring-6 group-data-dragging/slider:data-active:ring-foreground/8 dark:group-data-dragging/slider:data-active:ring-foreground/15"
              render={(thumbProps, state) => (
                <div
                  {...thumbProps}
                  data-active={
                    state.activeThumbIndex === index ? "" : undefined
                  }
                >
                  {showValue ? (
                    <span
                      aria-hidden="true"
                      dir="auto"
                      data-slot="slider-thumb-value"
                      className={cn(
                        "pointer-events-none absolute rounded-md bg-foreground px-1.5 py-0.5 text-xs/4 font-medium whitespace-nowrap text-background tabular-nums opacity-0 shadow-md/10 transition-[opacity,scale] duration-100 ease-out-quint group-has-[:focus-visible]/slider-thumb:opacity-100 group-has-[:focus-visible]/slider-thumb:duration-150 group-data-range-dragging/slider:opacity-100 group-data-range-dragging/slider:duration-150 group-data-dragging/slider:group-data-active/slider-thumb:opacity-100 group-data-dragging/slider:group-data-active/slider-thumb:duration-150 motion-safe:scale-96 motion-safe:group-has-[:focus-visible]/slider-thumb:scale-100 motion-safe:group-data-range-dragging/slider:scale-100 motion-safe:group-data-dragging/slider:group-data-active/slider-thumb:scale-100",
                        layout.value
                      )}
                    >
                      {Number.isFinite(state.values[index])
                        ? formatter.format(state.values[index])
                        : null}
                    </span>
                  ) : null}
                  {thumbProps.children}
                </div>
              )}
            />
          ))}
        </SliderPrimitive.Control>
      </SliderPrimitive.Root>
    </DirectionProvider>
  )
}

function SliderLabel({ className, ...props }: SliderPrimitive.Label.Props) {
  return (
    <SliderPrimitive.Label
      data-slot="slider-label"
      className={mergeClassName(
        "min-w-0 flex-1 text-sm font-medium wrap-anywhere select-none",
        className
      )}
      {...props}
    />
  )
}

function SliderValue({ className, ...props }: SliderPrimitive.Value.Props) {
  return (
    <SliderPrimitive.Value
      data-slot="slider-value"
      className={mergeClassName(
        "ms-auto shrink-0 text-sm text-muted-foreground tabular-nums",
        className
      )}
      {...props}
    />
  )
}

export { Slider, SliderLabel, SliderValue, sliderVariants }
export type { SliderProps }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"
```

```tsx
<Slider defaultValue={[50]} max={100} step={1} aria-label="Volume" />

<Slider defaultValue={50}>
  <SliderLabel>Volume</SliderLabel>
  <SliderValue />
</Slider>
```

`<Slider />` draws one thumb per value, so a number or a one-item array makes a single slider and two items make a range. Children such as `<SliderLabel />` and `<SliderValue />` sit on one line above the track.

While you drag, the thumb follows the pointer exactly. When the value jumps, from a press on the track, a key or a new controlled value, the thumb and fill glide there in 180ms instead of teleporting.

## Composition

```text
Slider
├── SliderLabel
└── SliderValue
```

## Examples

### Sizes

`sm`, `default` and `lg` scale the thumb and track together.

```tsx title="components/examples/slider/sizes.tsx"
import { Slider } from "@/components/ui/slider"

export function SliderSizes() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-8">
      <Slider size="sm" defaultValue={25} aria-label="Small" />
      <Slider defaultValue={50} aria-label="Default" />
      <Slider size="lg" defaultValue={75} aria-label="Large" />
    </div>
  )
}
```

### Range

Pass two values for a range. Name each thumb with `getAriaLabel`, and keep them apart with `minStepsBetweenValues`. With `draggableRange`, drag anywhere between the thumbs to move both at once; a tap there still moves the nearest thumb.

```tsx title="components/examples/slider/range.tsx"
"use client"

import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderRange() {
  return (
    <Slider
      defaultValue={[200, 800]}
      max={1000}
      step={10}
      minStepsBetweenValues={10}
      draggableRange
      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
      getAriaLabel={(index) =>
        index === 0 ? "Minimum price" : "Maximum price"
      }
      className="max-w-xs"
    >
      <SliderLabel>Price</SliderLabel>
      <SliderValue />
    </Slider>
  )
}
```

### Value bubble

`showValue` shows the formatted value above the thumb while it is dragged or focused from the keyboard. Use it instead of a label row, since the bubble rises into the space above the track.

```tsx title="components/examples/slider/value-bubble.tsx"
import { Slider } from "@/components/ui/slider"

export function SliderValueBubble() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-10 pt-8">
      <Slider
        defaultValue={0.4}
        max={1}
        step={0.01}
        format={{ style: "percent" }}
        showValue
        aria-label="Opacity"
      />
      <Slider defaultValue={[30, 70]} showValue aria-label="Range" />
    </div>
  )
}
```

### Steps and format

`step` snaps the value, `largeStep` sets the Page Up and Shift + arrow jump, and `format` formats every number the slider shows or announces.

```tsx title="components/examples/slider/steps.tsx"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderSteps() {
  return (
    <Slider
      defaultValue={1}
      min={0.5}
      max={2}
      step={0.25}
      largeStep={0.5}
      format={{ minimumFractionDigits: 2 }}
      className="max-w-xs"
    >
      <SliderLabel>Playback speed</SliderLabel>
      <SliderValue />
    </Slider>
  )
}
```

### Controlled

`onValueChange` fires on every move and `onValueCommitted` once on release, which is the moment to save. Setting the value from outside glides too.

```tsx title="components/examples/slider/controlled.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderControlled() {
  const [value, setValue] = React.useState(40)
  const [saved, setSaved] = React.useState(40)

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <Slider
        value={value}
        onValueChange={setValue}
        onValueCommitted={setSaved}
      >
        <SliderLabel>Brightness</SliderLabel>
        <SliderValue />
      </Slider>
      <div className="flex items-center justify-between gap-3">
        <span className="text-sm text-muted-foreground">Saved: {saved}</span>
        <div className="flex gap-2">
          <Button variant="outline" size="sm" onClick={() => setValue(0)}>
            Off
          </Button>
          <Button variant="outline" size="sm" onClick={() => setValue(100)}>
            Max
          </Button>
        </div>
      </div>
    </div>
  )
}
```

### Vertical

With `orientation="vertical"` the slider fills its parent's height, and Up raises the value.

```tsx title="components/examples/slider/vertical.tsx"
import { Slider } from "@/components/ui/slider"

const bands = [
  { label: "60 Hz", value: 70 },
  { label: "250 Hz", value: 45 },
  { label: "1 kHz", value: 55 },
  { label: "4 kHz", value: 80 },
  { label: "16 kHz", value: 35 },
]

export function SliderVertical() {
  return (
    <div className="flex h-48 gap-6">
      {bands.map((band) => (
        <div key={band.label} className="flex flex-col items-center gap-3">
          <Slider
            orientation="vertical"
            defaultValue={band.value}
            aria-label={band.label}
          />
          <span className="text-xs text-muted-foreground tabular-nums">
            {band.label}
          </span>
        </div>
      ))}
    </div>
  )
}
```

### With icons

Place icons beside the slider in a flex row. The slider takes the remaining width.

```tsx title="components/examples/slider/icons.tsx"
import { IconVolume, IconVolume3 } from "@tabler/icons-react"

import { Slider } from "@/components/ui/slider"

export function SliderIcons() {
  return (
    <div className="flex w-full max-w-xs items-center gap-3 text-muted-foreground">
      <IconVolume3 className="size-4 shrink-0" aria-hidden />
      <Slider defaultValue={50} aria-label="Volume" />
      <IconVolume className="size-4 shrink-0" aria-hidden />
    </div>
  )
}
```

### Disabled

The whole slider dims and ignores pointer and keyboard input.

```tsx title="components/examples/slider/disabled.tsx"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderDisabled() {
  return (
    <Slider defaultValue={30} disabled className="max-w-xs">
      <SliderLabel>Bass boost</SliderLabel>
      <SliderValue />
    </Slider>
  )
}
```

### Long labels

Long labels wrap, and the value stays on the end.

```tsx title="components/examples/slider/long-label.tsx"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderLongLabel() {
  return (
    <Slider
      defaultValue={[1200, 48000]}
      min={0}
      max={50000}
      step={100}
      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
      className="max-w-xs"
    >
      <SliderLabel>
        Annual household income before taxes, including side projects
      </SliderLabel>
      <SliderValue />
    </Slider>
  )
}
```

### Right to left

Inside a right-to-left parent the fill starts from the right and the arrow keys follow it. Pass `locale` for the reader's digits.

```tsx title="components/examples/slider/rtl.tsx"
import { Slider, SliderLabel, SliderValue } from "@/components/ui/slider"

export function SliderRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-xs flex-col gap-8">
      <Slider defaultValue={65} locale="ar-EG">
        <SliderLabel>مستوى الصوت</SliderLabel>
        <SliderValue />
      </Slider>
      <Slider defaultValue={[20, 70]} locale="ar-EG" draggableRange>
        <SliderLabel>النطاق</SliderLabel>
        <SliderValue />
      </Slider>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves focus to the next thumb. |
| `→` `↑` | Increases the value by one step. → decreases it in right-to-left. |
| `←` `↓` | Decreases the value by one step. ← increases it in right-to-left. |
| `Shift + Arrow` `Page Up` `Page Down` | Moves by largeStep. |
| `Home` | Sets the minimum, or the previous thumb's value in a range. |
| `End` | Sets the maximum, or the next thumb's value in a range. |

## Accessibility

- Each thumb holds a native `input type="range"`, so it is a `slider` with its own value, min and max.
- `<SliderLabel />` names every thumb. Without one, `aria-label` on `<Slider />` is passed to the thumbs, not the wrapper, so the slider is never left unnamed. In a range, use `getAriaLabel` to tell the thumbs apart.
- `<SliderValue />` and the value bubble are not announced on every change; the thumb already reports its value.
- Pressing a thumb doesn't show a focus ring; the arrow keys do. With reduced motion the thumb jumps instead of gliding.
- On touch screens the hit area grows to at least 44px without changing the layout.

## API reference

Built on the Base UI slider. Every part accepts the props of the primitive it wraps.

### Slider

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number \| number[]` | – | The controlled value. One thumb per item. |
| `defaultValue` | `number \| number[]` | – |  |
| `onValueChange` | `(value, eventDetails) => void` | – | Fires on every change while dragging. |
| `onValueCommitted` | `(value, eventDetails) => void` | – | Fires once when a change ends. |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` |  |
| `largeStep` | `number` | `10` |  |
| `minStepsBetweenValues` | `number` | `0` |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `draggableRange` | `boolean` | `false` | In a range, dragging between the thumbs moves them together, keeping the gap. |
| `showValue` | `boolean` | `false` | Shows the value above the thumb while dragging or keyboard-focused. |
| `thumbAlignment` | `"edge" \| "center" \| "edge-client-only"` | `"edge"` | edge keeps the thumb inside the track at min and max. |
| `thumbCollisionBehavior` | `"push" \| "swap" \| "none"` | `"push"` |  |
| `format` | `Intl.NumberFormatOptions` | – |  |
| `locale` | `Intl.LocalesArgument` | `"en-US"` |  |
| `aria-label` | `string` | – | Names the thumbs. |
| `getAriaLabel` | `(index: number) => string` | – | Names each thumb of a range. |
| `getAriaValueText` | `(formattedValue: string, value: number, index: number) => string` | – |  |
| `name` | `string` | – |  |
| `disabled` | `boolean` | `false` |  |
| `className` | `string \| (state) => string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="slider"` | The root. |
| `data-size` | sm, default or lg. |
| `data-jump` | Present for 220ms while the thumb glides to a new value. |
| `data-range-dragging` | Present while the whole range is being dragged. |
| `data-orientation` | horizontal or vertical. |
| `data-dragging` | Present while a pointer is down. |
| `data-disabled` | Present when disabled. |
| `data-invalid` | Present when invalid inside a Field. |
| `--slider-thumb` | The thumb's diameter, set by size. |
| `--slider-track` | The track's thickness, set by size. |
| `data-slot="slider-control"` | The pressable area around the track. |
| `data-slot="slider-track"` | The track. |
| `data-slot="slider-range"` | The fill between the start, or the first thumb, and the last thumb. |
| `data-slot="slider-thumb"` | Each thumb. data-index gives its position. |
| `data-active` | On the thumb that is focused or being dragged. |
| `data-slot="slider-thumb-value"` | The value bubble from showValue. |

### SliderLabel

Names the thumbs and grows to fill the row. Renders a `<div>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="slider-label"` | The label. |

### SliderValue

Shows the formatted values, joined with an en dash in a range. Renders an `<output>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(formattedValues: string[], values: number[]) => ReactNode` | – | Custom text. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<output>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="slider-value"` | The value. |
| `data-orientation` | horizontal or vertical. |
| `data-dragging` | Present while a pointer is down. |
| `data-disabled` | Present when disabled. |
| `data-invalid` | Present when invalid inside a Field. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
