Slider

An input where the user selects a value from within a given range.

import * as stylex from "@stylexjs/stylex"
import { Slider } from "@/components/ui/slider"

const styles = stylex.create({
  slider: { maxWidth: "20rem" },
})

export default function SliderDemo() {
  return <Slider defaultValue={[75]} max={100} step={1} xstyle={styles.slider} />
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The slider imports both. The examples caption the slider with Field.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/slider.tsx
"use client"

import type { CSSProperties } from "react"
import { use, useState } from "react"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"

import { stateStyles } from "@/lib/state-styles"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, radius, shadow, space } from "@/theme/tokens.stylex"

const thumbEdge = "oklch(0 0 0 / 8%)"
const sliderThumbBg = `light-dark(${color.background}, ${color.foreground})`

const styles = stylex.create({
  root: {
    width: "100%",
  },
  rootVertical: {
    width: null,
    height: "100%",
  },
  control: {
    position: "relative",
    display: "flex",
    alignItems: "center",
    width: "100%",
    touchAction: "none",
    userSelect: "none",
  },
  controlVertical: {
    flexDirection: "column",
    width: "auto",
    height: "100%",
    minHeight: "10rem",
  },
  track: {
    position: "relative",
    flexGrow: 1,
    width: "100%",
    height: space.s1,
    overflow: "hidden",
    borderRadius: radius.full,
    backgroundColor: color.muted,
    boxShadow: shadow.groove,
  },
  trackVertical: {
    width: space.s1,
    height: "100%",
  },
  range: {
    backgroundColor: color.primary,
  },
  thumb: {
    width: space.s3,
    height: space.s3,
    borderRadius: radius.full,
    borderWidth: 1,
    borderStyle: "solid",
    borderColor: thumbEdge,
    backgroundColor: sliderThumbBg,
    backgroundImage: `linear-gradient(to bottom, transparent, ${color.thumbShade})`,
    backgroundOrigin: "border-box",
    outlineStyle: "none",
    boxShadow: {
      default: shadow.thumb,
      ":hover": {
        default: shadow.thumb,
        "@media (hover: hover)": `${shadow.thumb}, ${shadow.ring}`,
      },
    },
    transitionProperty: "box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "::after": {
      content: '""',
      position: "absolute",
      inset: `calc(-1 * ${space.s2})`,
    },
  },
  thumbRing: { borderColor: color.ring, boxShadow: `${shadow.thumb}, ${shadow.ring}` },
})

const sliderStyles = {
  root: styles.root,
  rootVertical: styles.rootVertical,
  control: styles.control,
  controlVertical: styles.controlVertical,
  disabled: states.disabled,
  track: styles.track,
  trackVertical: styles.trackVertical,
  range: styles.range,
  thumb: styles.thumb,
  thumbRing: styles.thumbRing,
}

type StateOverrides = { xstyle?: StyleXStyles; style?: CSSProperties; className?: string }

type SliderProps<Value extends number | readonly number[]> = Omit<
  SliderPrimitive.Root.Props<Value>,
  "className" | "style"
> &
  StateOverrides

function Slider<Value extends number | readonly number[]>({
  xstyle,
  style,
  className,
  value,
  defaultValue,
  ...props
}: SliderProps<Value>) {
  const count = Array.isArray(value)
    ? value.length
    : Array.isArray(defaultValue)
      ? defaultValue.length
      : 1

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      value={value}
      defaultValue={defaultValue}
      thumbAlignment="edge"
      {...props}
      {...stateStyles(
        (state) => [styles.root, state.orientation === "vertical" && styles.rootVertical, xstyle],
        { className, style },
      )}
    >
      <SliderPrimitive.Control
        data-slot="slider-control"
        {...stateStyles((state) => [
          styles.control,
          state.orientation === "vertical" && styles.controlVertical,
          state.disabled && states.disabled,
        ])}
      >
        <SliderPrimitive.Track
          data-slot="slider-track"
          {...stateStyles((state) => [
            styles.track,
            state.orientation === "vertical" && styles.trackVertical,
          ])}
        >
          <SliderPrimitive.Indicator data-slot="slider-range" {...stylex.props(styles.range)} />
        </SliderPrimitive.Track>
        {Array.from({ length: count }, (_, index) => (
          <SliderThumb
            key={index}
            index={index}
            invalid={props["aria-invalid"] === true || props["aria-invalid"] === "true"}
          />
        ))}
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

function SliderThumb({ index, invalid }: { index: number; invalid: boolean }) {
  const fieldState = use(FieldStateContext)
  const [focusVisible, setFocusVisible] = useState(false)

  return (
    <SliderPrimitive.Thumb
      data-slot="slider-thumb"
      data-focus-visible={focusVisible ? "" : undefined}
      index={index}
      onFocus={(event) => setFocusVisible(event.target.matches(":focus-visible"))}
      onBlur={() => setFocusVisible(false)}
      onKeyDown={() => setFocusVisible(true)}
      {...stateStyles((state) => [
        styles.thumb,
        (state.valid === false || fieldState?.valid === false || invalid) && states.invalidRing,
        (focusVisible || (state.dragging && state.activeThumbIndex === index)) && styles.thumbRing,
      ])}
    />
  )
}

export { Slider, sliderStyles }

Update the import paths to match your project setup.

Usage

import { Slider } from "@/components/ui/slider"
<Slider defaultValue={[33]} max={100} step={1} />

Slider renders the control, the track, the filled range and the thumbs itself. It fills its container, so set the width on the slider or its parent. value and defaultValue take a number for one thumb or an array for one thumb per entry.

Slider
└── control
    ├── track
    │   └── range
    └── thumb

Inside Field, use FieldLabel for automatic association. For a bare slider, pass aria-labelledby. Base UI copies it to the <input> inside every thumb, and a screen reader announces that input. A label's htmlFor cannot reach an input the slider renders itself.

<Label id="volume">Volume</Label>
<Slider aria-labelledby="volume" defaultValue={[40]} />

Range

Use an array with two values for a range slider.

import * as stylex from "@stylexjs/stylex"
import { Slider } from "@/components/ui/slider"

const styles = stylex.create({
  slider: { maxWidth: "20rem" },
})

export default function SliderRange() {
  return <Slider defaultValue={[25, 50]} max={100} step={5} xstyle={styles.slider} />
}

Multiple thumbs

Use an array with more than two values for a thumb per value. Thumbs push each other when they meet; set thumbCollisionBehavior to "swap" or "none" to change that.

import * as stylex from "@stylexjs/stylex"
import { Slider } from "@/components/ui/slider"

const styles = stylex.create({
  slider: { maxWidth: "20rem" },
})

export default function SliderMultiple() {
  return <Slider defaultValue={[10, 20, 70]} max={100} step={10} xstyle={styles.slider} />
}

Vertical

Use orientation="vertical" for a vertical slider. It takes the height of its parent, with a minimum of 10rem when the parent has no height.

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

export default function SliderVertical() {
  return (
    <HStack gap={6} align="center" justify="center">
      <Slider defaultValue={[50]} max={100} step={1} orientation="vertical" />
      <Slider defaultValue={[25]} max={100} step={1} orientation="vertical" />
    </HStack>
  )
}

Controlled

Pass value and onValueChange to control the slider. onValueCommitted fires once when a drag or a key press ends. Use it for work that costs more than a state update.

0.3, 0.7
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Field, FieldLabel } from "@/components/ui/field"
import { Slider } from "@/components/ui/slider"
import { HStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  panel: { width: "100%", maxWidth: "20rem" },
  value: {
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontVariantNumeric: "tabular-nums",
  },
})

export default function SliderControlled() {
  const [value, setValue] = useState([0.3, 0.7])

  return (
    <Field xstyle={styles.panel}>
      <HStack gap={2} align="center" justify="between">
        <FieldLabel>Temperature</FieldLabel>
        <span {...stylex.props(styles.value)}>{value.join(", ")}</span>
      </HStack>
      <Slider value={value} onValueChange={setValue} min={0} max={1} step={0.1} />
    </Field>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { VStack } from "@/components/ui/stack"
import { Field, FieldLabel } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import { Slider } from "@/components/ui/slider"

const styles = stylex.create({
  slider: { maxWidth: "20rem" },
})

function BareDisabled() {
  return <Slider defaultValue={[50]} max={100} step={1} disabled xstyle={styles.slider} />
}

export default function DisabledExample() {
  return (
    <VStack gap={5}>
      <BareDisabled />
      <Field disabled>
        <FieldLabel>Volume</FieldLabel>
        <Slider defaultValue={50} />
      </Field>
    </VStack>
  )
}

Invalid

Set invalid on Field or aria-invalid on a bare slider.

import { Slider } from "@/components/ui/slider"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

export default function SliderInvalid() {
  return (
    <VStack gap={5} width="100%" maxWidth="24rem">
      <VStack gap={2}>
        <Label id="slider-invalid-bare">Volume</Label>
        <Slider
          aria-labelledby="slider-invalid-bare"
          defaultValue={25}
          aria-invalid
          aria-describedby="slider-invalid-bare-error"
        />
        <FieldError id="slider-invalid-bare-error">Choose a volume.</FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Volume</FieldLabel>
        <Slider defaultValue={25} />
        <FieldError>Choose a volume.</FieldError>
      </Field>
    </VStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Slider wraps the Base UI Slider.Root and accepts its props. See the Base UI reference. It also takes xstyle, style and className and applies them to the root.

Slider

PropTypeDefault
valuenumber | readonly number[]—
defaultValuenumber | readonly number[]min
onValueChange(value, eventDetails) => void—
onValueCommitted(value, eventDetails) => void—
minnumber0
maxnumber100
stepnumber1
largeStepnumber10
minStepsBetweenValuesnumber0
orientation"horizontal" | "vertical""horizontal"
disabledbooleanfalse
namestring—
thumbCollisionBehavior"push" | "swap" | "none""push"
thumbAlignment"center" | "edge" | "edge-client-only""edge"
formatIntl.NumberFormatOptions—
localeIntl.LocalesArgument—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

The root is a <div role="group">. Each thumb wraps an <input type="range">, so the arrow keys, Home, End, Page Up and Page Down move it, Shift with an arrow moves by largeStep, and a name submits every value with a form. format and locale format the aria-valuetext a screen reader reads out.

sliderStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
rootThe full-width root
rootVerticalThe full-height root
controlThe gesture surface: flex row, no touch scrolling
controlVerticalThe column, with its 10rem floor
disabledThe dimmed, non-interactive state
trackThe groove: height, fill, inset shadow, clipping
trackVerticalThe groove turned on its side
rangeThe primary fill
thumbThe raised circle: edge, lit face, lift, hover ring
thumbRingThe ring while focused from the keyboard or dragged

disabled is the shared states.disabled from theme/states.ts. thumbRing is the shared ring with no pseudo-class on it, with the thumb's lift kept under the halo. A thumb needs a ring without a pseudo-class because focus goes to the <input> nested inside it. The hover halo stays on thumb, since the thumb itself receives the hover.

Data attributes

AttributeOnEffect
data-slot="slider"the rootTargets the slider from a parent
data-slot="slider-control"the controlTargets the gesture surface
data-slot="slider-track", data-slot="slider-range"the grooveTargets the track and its fill
data-slot="slider-thumb"a thumbTargets the circle
data-orientation, data-disabled, data-draggingevery partBase UI writes them
data-indexa thumbBase UI writes the thumb's position in value
data-focus-visiblea thumbSet while the ring shows for keyboard focus