Skip to content
Primitiv home
Framework
Consumption mode

Slider

stableSource Figma

A draggable, keyboard-accessible control for selecting one value (or a range) along a track.

Playground

Density

Preview

Size
import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
<Slider size="md" defaultValue={[40]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-label="Volume" /></Slider>

Density is set by a data-density ancestor — the Context system, not a Slider prop.

Installation

npx primitiv add slider

Import

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

Copied into your project as .primitiv-slider — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

Single value

<Slider defaultValue={[40]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb /></Slider>

Range

<Slider defaultValue={[20, 60]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-label="Minimum" />  <SliderThumb aria-label="Maximum" /></Slider>

Props

Slider.Root

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the child element in place of the default <span> via the Slot pattern, merging Slider.Root's dir, data-* hooks, pointer handler, and ref onto it.
defaultValuenumber[]headlessThe value array on first render — one number per SliderThumb, in order ([value] for a single thumb, [low, high] for a range). After mount the component owns the value. Defaults to [min] when omitted. Forbidden in controlled mode — use value instead.
dir"ltr" | "rtl"headlessReading direction of a horizontal slider; see SliderDirection. Affects which end is the minimum, the arrow-key direction, pointer mapping, and the inline positioning styles. When omitted, it is inherited from the nearest DirectionProvider, falling back to "ltr" when there is no provider; an explicit prop always wins.
disabledbooleanfalseheadlessDisable all interaction. Every part receives data-disabled="", thumbs leave the tab order and gain aria-disabled="true", and both keyboard and pointer handlers become no-ops.
invertedbooleanfalseheadlessReverse the direction the value increases along the axis. Combines with SliderRootSharedProps.orientation`orientation` and SliderRootSharedProps.dir`dir` to flip the pointer mapping, arrow keys, and the edge the thumb/range offset is anchored to.
maxnumber100headlessHighest value the slider can take — the value at the track's end edge. Must be strictly greater than SliderRootSharedProps.min`min`; Slider.Root throws during render otherwise.
minnumber0headlessLowest value the slider can take — the value at the track's start edge. Must be strictly less than SliderRootSharedProps.max`max`; Slider.Root throws during render otherwise.
minStepsBetweenThumbsnumber0headlessMinimum gap, expressed in steps, enforced between adjacent thumbs on a multi-thumb slider. The gap in value units is minStepsBetweenThumbs * step. A thumb is clamped so it can never come closer than this to its neighbours (and can never cross them). Has no effect on a single-thumb slider.
namestringheadlessWhen set, Slider.Root renders one hidden <input> per thumb carrying that thumb's value so the slider posts with a surrounding <form>. A multi-thumb slider suffixes the name with [] (e.g. name="range"range[]); a single-thumb slider uses the bare name. When omitted, no hidden inputs are rendered.
onValueChange(value: number[]) => voidheadlessCalled with the full next value array on every change (each arrow-key press and every pointer increment during a drag). Optional in uncontrolled mode. Called with the full next value array on every change (each arrow-key press and every pointer increment during a drag). Update value from it to keep the slider in sync.
onValueCommit(value: number[]) => voidheadlessCalled once with the settled value array when an interaction ends — a pointer release, or a completed keyboard press. Use it to persist only the final value rather than every intermediate step.
orientation"horizontal" | "vertical""horizontal"headlessLayout axis of the track; see SliderOrientation. Sets data-orientation and each thumb's aria-orientation, and selects which pointer axis and arrow keys drive the value.
stepnumber1headlessGranularity of the value: every thumb value snaps to a multiple of step anchored at min, and each arrow-key press moves by one step (Page Up/Down move by ten). Fractional steps (e.g. 0.1) are snapped to the step's decimal precision. Must be greater than 0; Slider.Root throws during render otherwise.
valuenumber[]headlessForbidden in uncontrolled mode — use defaultValue instead. The current value array — one number per SliderThumb, in order ([value] for a single thumb, [low, high] for a range). Must be kept in sync by the parent via onValueChange.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledTrack thickness + thumb size; data-density scales each size further.

Slider.Track

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the child element in place of the default <span> via the Slot pattern, merging the data-orientation / data-disabled hooks onto it.

Slider.Range

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the child element in place of the default <span> via the Slot pattern, merging the data-orientation / data-disabled hooks and the computed inline positioning style onto it.

Slider.Thumb

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the child element in place of the default <span> via the Slot pattern, merging the role="slider" semantics, aria-value* attributes, keyboard handler, positioning style, and ref onto it.

Styling contract

--primitiv-slider-track-thickness--primitiv-slider-track-bg--primitiv-slider-range-bg--primitiv-slider-thumb-size--primitiv-slider-thumb-bg--primitiv-slider-thumb-border-color--primitiv-slider-thumb-border-width--primitiv-slider-thumb-ring-gap-size--primitiv-slider-thumb-ring-size--primitiv-slider-thumb-glow-color--primitiv-slider-thumb-glow-opacity

Keyboard

KeyBehaviour
ArrowRight / ArrowUpIncrease by step.
ArrowLeft / ArrowDownDecrease by step.
PageUp / PageDownIncrease / decrease by ten steps — what makes a wide range usable without coarsening step.
Home / EndJump to min / max. On a range, a thumb still cannot cross its neighbour, so it stops at the gap.
TabMove between thumbs — a range slider is two tab stops, not one.

Data attributes

Slider

className: .primitiv-slider

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-disabled""disabled

SliderTrack

className: .primitiv-slider__track

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-disabled""disabled

SliderRange

className: .primitiv-slider__range

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-disabled""disabled

SliderThumb

className: .primitiv-slider__thumb

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-disabled""disabled

Accessibility

  • Every thumb is a role="slider" carrying aria-valuenow, aria-valuemin and aria-valuemax, so the position is announced without any work from you. What is not automatic is the name — the control announces "40" and nothing else unless you label it.
  • Label the Thumb, never the Root. The Root renders a plain <span> with no role, so an aria-label on it is attached to nothing and announced nowhere — a silent failure, since the slider still looks and behaves correctly. Slider.Root's own JSDoc example shows aria-label on the Root, which is misleading; the Thumb's example has it right.
  • A Field label does not reach the thumb. Only Input, Textarea and Select read FieldContext, and a <label htmlFor> cannot associate with a <span role="slider"> anyway — so Field.Label beside a slider renders a reference to an id nothing claims. Give the label an id and point aria-labelledby at it from each thumb.
  • On a range, label the thumbs separately. Two thumbs sharing one label are announced identically, which makes it impossible to tell by ear which end you are on — aria-label="Minimum" and "Maximum" is the smallest fix.
  • A slider is a poor fit for a value that has to be exact. Pair it with a number input when precision matters, and keep the two in sync — the slider for the rough gesture, the field for the specific figure.
  • onValueCommit is the accessible place for expensive work, not a debounce. A keyboard user pressing an arrow key commits immediately on that press, so a debounce keyed to pointer movement makes the keyboard path feel broken while the mouse path feels fine.
  • disabled removes the thumbs from the tab order. If the range is unavailable for a reason the user could act on, explain it in nearby text — a disabled slider announces nothing about why.
  • The arrow keys follow orientation, dir and inverted, so "increase" always matches the direction the thumb visibly moves. Do not re-map the keys yourself to compensate for an inverted track; set inverted and let the component handle both.

Examples

A range (two thumbs)

Render a second Slider.Thumb and the slider becomes a range — there is no range prop. The value is an array either way, so two thumbs means [low, high], and Range fills the span between them rather than from the start. A thumb cannot cross its neighbour, and minStepsBetweenThumbs keeps a gap between them (10 here — try to close it).

Density

Value: [20, 60]

import { useState } from "react";import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
const [value, setValue] = useState([20, 60]);
<Slider value={value} onValueChange={setValue} minStepsBetweenThumbs={10}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-label="Minimum" />  <SliderThumb aria-label="Maximum" /></Slider>

onValueChange vs onValueCommit

Drag the thumb and watch the two counters. onValueChange fires on every increment — each arrow press, each pointer move — and is what you use to keep controlled state in sync. onValueCommit fires once, when the interaction ends. Anything expensive belongs on the second: a request per intermediate value is the failure this pair exists to prevent.

Density

onValueChange: 0 · onValueCommit: 0

import { useState } from "react";import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
// keep the UI in sync on every step...// ...but only persist the settled value<Slider defaultValue={[40]} onValueChange={setPreview} onValueCommit={save}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb /></Slider>

Min, max and step

min, max and step behave as on a native range input, and step is what the arrow keys move by — so a coarse step is also a coarser keyboard. Page Up / Page Down always move ten steps, which is what keeps a 0–1000 slider usable from the keyboard without making step itself unhelpfully large.

Density
import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
<Slider min={0} max={1000} step={50} defaultValue={[400]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-label="Budget" /></Slider>

Labelling

The name goes on the Thumb, not the Root. The Thumb is the role="slider" — it carries aria-valuenow and announces the position — while the Root is a plain <span> with no role, so an aria-label there is announced nowhere at all. A visible label needs an id with aria-labelledby on the thumb; Field does not do this for you, because only Input, Textarea and Select read FieldContext, so a Field.Label beside a slider points at an id nothing claims. On a range, label each thumb separately or both announce the same thing.

Density
Volume
import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
// a visible label needs an id, and the THUMB points at it<span id="volume-label">Volume</span><Slider defaultValue={[40]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-labelledby="volume-label" /></Slider>
// no visible label? name the thumb directly<SliderThumb aria-label="Volume" />

Disabled

disabled on the Root takes every thumb out of the tab order and stops pointer interaction, and puts data-disabled on the root and each part for styling. Note it belongs on the Root, not on a Thumb — the slider is one control even when it has two handles.

Density
import { Slider, SliderTrack, SliderRange, SliderThumb } from "@/components/ui/slider";
<Slider disabled defaultValue={[40]}>  <SliderTrack>    <SliderRange />  </SliderTrack>  <SliderThumb aria-label="Volume" /></Slider>