import { Slider } from "@/components/ui/slider" ;
export default function SliderDemo () {
return < Slider className = "max-w-xs" label = "Volume" defaultValue = { 60 } />;
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/slider
The CLI installs dependencies and any other components this one uses.
import { Slider } from "@/components/ui/slider" ;
< Slider label = "Volume" defaultValue = { 60 } />
< Slider label = "Price" defaultValue = {[ 200 , 800 ]} maxValue = { 1000 } />
Pass a number for a single thumb, or an array for one thumb per value. The type of value, defaultValue and onChange follows whichever you pass.
Slider — an approximate value within a known range where relative position matters more than precision: volume, opacity, a price range.
Number Field — an exact number, or a range too large to drag through. Pair the two when users need both (example ).
Radio Group or Select — a handful of discrete, named options rather than a continuous scale.
Progress Bar or Meter — displaying a value users can't change.
< Slider > { /* root: label, output, track, marks */ }
Label { /* from `label` */ }
Output { /* formatted value, from `showOutput` */ }
Track { /* background, filled range and one thumb per value */ }
Marks { /* from `marks` */ }
</ Slider >
Part Renders Notes Slider<div role="group">Root. Sets the tone variables from color. Label <label>Rendered from label. Output <output>Current value(s), formatted with formatOptions. Two values are formatted as a locale-aware range. Hidden with showOutput={false}. Track <div>Muted background plus a filled segment from the start (or between two thumbs). Thumb <div> + hidden <input type="range">One per value. Grows a soft ring on hover, focus and drag. Marks <div aria-hidden>Numeric labels positioned along the track from marks. Hidden from screen readers, which announce the thumb's value instead.
Pass an array to defaultValue (or value) for two thumbs. The fill spans between them, and thumbs can't cross each other. Each thumb gets its own accessible label: two-thumb sliders default to "Minimum" and "Maximum", and thumbLabels sets your own (e.g. ["Start", "End"]).
import { Slider } from "@/components/ui/slider" ;
export default function SliderRange () {
return (
< Slider
className = "max-w-xs"
label = "Price range"
defaultValue = {[ 200 , 800 ]}
maxValue = { 1000 }
step = { 10 }
formatOptions = {{
style: "currency" ,
currency: "USD" ,
maximumFractionDigits: 0 ,
}}
/>
);
}
minValue and maxValue default to 0 and 100, and step to 1. Values snap to the step while dragging and with the keyboard.
import { Slider } from "@/components/ui/slider" ;
export default function SliderStep () {
return (
< Slider
className = "max-w-xs"
label = "Thermostat"
defaultValue = { 21.5 }
minValue = { 16 }
maxValue = { 28 }
step = { 0.5 }
formatOptions = {{ style: "unit" , unit: "celsius" }}
/>
);
}
formatOptions takes any Intl.NumberFormat options. The output and the value screen readers announce are both formatted in the user's locale: percentages, currencies and units.
import { Slider } from "@/components/ui/slider" ;
export default function SliderFormatting () {
return (
< div className = "flex w-full max-w-xs flex-col gap-6" >
< Slider
label = "Opacity"
defaultValue = { 0.8 }
maxValue = { 1 }
step = { 0.01 }
formatOptions = {{ style: "percent" }}
/>
< Slider
label = "Monthly budget"
defaultValue = { 2500 }
maxValue = { 10000 }
step = { 100 }
formatOptions = {{
style: "currency" ,
currency: "EUR" ,
maximumFractionDigits: 0 ,
}}
/>
< Slider
label = "Upload limit"
defaultValue = { 250 }
minValue = { 10 }
maxValue = { 1000 }
step = { 10 }
formatOptions = {{ style: "unit" , unit: "megabyte" }}
/>
</ div >
);
}
By default the fill starts at minValue. For a single thumb, fillOffset starts it from another value instead, such as 0 on a -12 to 12 scale, so the fill grows in either direction. The equalizer in the next example uses fillOffset={0}.
orientation="vertical" stacks the label and output above a vertical track. Marks sit beside the track. A vertical slider is h-48 by default; set a height with className (e.g. h-56). Sizes, colors, ranges and marks all work the same way. Up and down arrows move the thumb.
import { Slider } from "@/components/ui/slider" ;
const bands = [
{ label: "60" , value: 4 },
{ label: "250" , value: 2 },
{ label: "1k" , value: - 1 },
{ label: "4k" , value: 3 },
{ label: "16k" , value: 5 },
];
export default function SliderVertical () {
return (
< div className = "flex items-end gap-8" >
< div className = "flex gap-4" >
{bands. map (( band ) => (
< Slider
key = {band.label}
orientation = "vertical"
label = {
<>
{band.label}
< span className = "sr-only" > Hz</ span >
</>
}
defaultValue = {band.value}
minValue = { - 12 }
maxValue = { 12 }
fillOffset = { 0 }
size = "sm"
color = "neutral"
formatOptions = {{ signDisplay: "exceptZero" }}
/>
))}
</ div >
< Slider
orientation = "vertical"
label = "Range"
defaultValue = {[ 20 , 80 ]}
marks = {[ 0 , 50 , 100 ]}
className = "h-56"
/>
</ div >
);
}
marks renders labels under the track at the given values. Match them to step so every mark is reachable. Marks show raw numbers; they don't use formatOptions.
import { Slider } from "@/components/ui/slider" ;
export default function SliderMarks () {
return (
< Slider
className = "max-w-xs"
label = "Retention (days)"
defaultValue = { 30 }
maxValue = { 90 }
step = { 15 }
marks = {[ 0 , 15 , 30 , 45 , 60 , 75 , 90 ]}
/>
);
}
color accepts any tone and defaults to brand. Use neutral for media controls and danger or warning only when the value itself signals risk.
import { Slider } from "@/components/ui/slider" ;
const colors = [
"brand" ,
"primary" ,
"neutral" ,
"info" ,
"success" ,
"warning" ,
"danger" ,
] as const ;
export default function SliderColors () {
return (
< div className = "flex w-full max-w-xs flex-col gap-5" >
{colors. map (( color , i ) => (
< Slider
key = {color}
label = {color}
color = {color}
defaultValue = { 30 + i * 10 }
showOutput = { false }
className = "capitalize"
/>
))}
</ div >
);
}
md is the default. sm has a thinner track and a smaller thumb for dense panels.
import { Slider } from "@/components/ui/slider" ;
export default function SliderSizes () {
return (
< div className = "flex w-full max-w-xs flex-col gap-6" >
< Slider label = "Small" size = "sm" defaultValue = { 40 } />
< Slider label = "Medium" size = "md" defaultValue = { 60 } />
</ div >
);
}
Set showOutput={false} and put icons on either side for a compact control. Without a label, an aria-label is required.
import { SunDimIcon, SunIcon } from "lucide-react" ;
import { Slider } from "@/components/ui/slider" ;
export default function SliderWithIcons () {
return (
< div className = "flex w-full max-w-xs items-center gap-3" >
< SunDimIcon
aria-hidden
className = "size-4 shrink-0 text-muted-foreground"
/>
< Slider
aria-label = "Screen brightness"
showOutput = { false }
defaultValue = { 70 }
color = "neutral"
className = "flex-1"
/>
< SunIcon aria-hidden className = "size-5 shrink-0 text-muted-foreground" />
</ div >
);
}
isDisabled dims the slider and removes every thumb from the tab order.
import { Slider } from "@/components/ui/slider" ;
export default function SliderDisabled () {
return (
< Slider
className = "max-w-xs"
label = "Replica count"
defaultValue = { 3 }
minValue = { 1 }
maxValue = { 10 }
isDisabled
/>
);
}
onChange fires continuously while dragging; onChangeEnd fires once when the user lets go (or after each key press). Update the UI with the first and save or fetch with the second.
Aa Bb Cc
onChange: 40px · onChangeEnd: 40px
import { useState } from "react" ;
import { Slider } from "@/components/ui/slider" ;
export default function SliderControlled () {
const [ value , setValue ] = useState ( 40 );
const [ committed , setCommitted ] = useState ( 40 );
return (
< div className = "flex w-full max-w-xs flex-col gap-4" >
< Slider
label = "Font size (px)"
value = {value}
onChange = {setValue}
onChangeEnd = {setCommitted}
minValue = { 12 }
maxValue = { 72 }
/>
< p className = "truncate text-muted-foreground" style = {{ fontSize: value }}>
Aa Bb Cc
</ p >
< p className = "text-muted-foreground text-xs" >
onChange: {value}px · onChangeEnd: {committed}px
</ p >
</ div >
);
}
Share one state between a slider and a NumberField so users can drag for speed or type for precision.
import { useState } from "react" ;
import { NumberField } from "@/components/ui/number-field" ;
import { Slider } from "@/components/ui/slider" ;
export default function SliderWithInput () {
const [ value , setValue ] = useState ( 64 );
return (
< div className = "flex w-full max-w-xs items-end gap-3" >
< Slider
label = "Memory (GB)"
showOutput = { false }
value = {value}
onChange = {setValue}
minValue = { 8 }
maxValue = { 256 }
step = { 8 }
className = "flex-1 pb-1.5"
/>
< NumberField
aria-label = "Memory in GB"
value = {value}
onChange = {( v ) => setValue (Number. isNaN (v) ? 8 : v)}
minValue = { 8 }
maxValue = { 256 }
step = { 8 }
className = "w-24"
/>
</ div >
);
}
Pass name and each thumb's hidden range input is submitted with the form. For a range, a string names every thumb, so read it with formData.getAll(name); an array such as ["ageMin", "ageMax"] names each thumb separately. form associates the inputs with a <form> elsewhere in the document by id. A disabled slider is not submitted.
import { useState } from "react" ;
import { Form } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { Slider } from "@/components/ui/slider" ;
export default function SliderForm () {
const [ result , setResult ] = useState < string | null >( null );
return (
< Form
className = "flex w-full max-w-xs flex-col gap-5"
onSubmit = {( e ) => {
e. preventDefault ();
const data = new FormData (e.currentTarget);
setResult (
JSON . stringify ({
quality: data. get ( "quality" ),
ageMin: data. get ( "ageMin" ),
ageMax: data. get ( "ageMax" ),
hours: data. getAll ( "hours" ),
}),
);
}}
>
< Slider label = "Image quality" name = "quality" defaultValue = { 80 } />
< Slider
label = "Audience age"
name = {[ "ageMin" , "ageMax" ]}
defaultValue = {[ 18 , 65 ]}
minValue = { 13 }
maxValue = { 100 }
/>
< Slider
label = "Working hours"
name = "hours"
defaultValue = {[ 9 , 17 ]}
minValue = { 0 }
maxValue = { 23 }
thumbLabels = {[ "Start" , "End" ]}
/>
< Button type = "submit" className = "self-start" >
Save
</ Button >
{result && (
< code className = "rounded-md bg-muted px-2 py-1 text-xs" >{result}</ code >
)}
</ Form >
);
}
A range slider kept in sync with two number fields, with a live result count.
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { NumberField } from "@/components/ui/number-field" ;
import { Slider } from "@/components/ui/slider" ;
const MIN = 0 ;
const MAX = 2000 ;
const prices = [
49 , 89 , 120 , 149 , 199 , 249 , 299 , 349 , 420 , 499 , 599 , 699 , 799 , 899 , 999 , 1199 ,
1499 , 1899 ,
];
const usd = {
style: "currency" ,
currency: "USD" ,
maximumFractionDigits: 0 ,
} as const ;
export default function SliderRecipePriceFilter () {
const [ range , setRange ] = useState ([ 100 , 900 ]);
const [ min , max ] = range;
const count = prices. filter (( p ) => p >= min && p <= max). length ;
return (
< div className = "flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-5" >
< Slider
label = "Price"
value = {range}
onChange = {setRange}
minValue = { MIN }
maxValue = { MAX }
step = { 10 }
formatOptions = {usd}
/>
< div className = "flex items-center gap-2" >
< NumberField
aria-label = "Minimum price"
value = {min}
onChange = {( v ) => setRange ([Math. min (v || MIN , max), max])}
minValue = { MIN }
maxValue = {max}
step = { 10 }
formatOptions = {usd}
stepper = "none"
/>
< span className = "text-muted-foreground" >–</ span >
< NumberField
aria-label = "Maximum price"
value = {max}
onChange = {( v ) => setRange ([min, Math. max (v || MAX , min)])}
minValue = {min}
maxValue = { MAX }
step = { 10 }
formatOptions = {usd}
stepper = "none"
/>
</ div >
< div className = "flex gap-2" >
< Button
variant = "ghost"
className = "flex-1"
onPress = {() => setRange ([ MIN , MAX ])}
>
Reset
</ Button >
< Button className = "flex-1" >Show {count} results</ Button >
</ div >
</ div >
);
}
Small neutral sliders driving CSS filters on a preview, with a reset.
import { RotateCcwIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { Slider } from "@/components/ui/slider" ;
const defaults = { brightness: 100 , contrast: 100 , saturation: 100 , blur: 0 };
type Adjustments = typeof defaults;
const controls : {
key : keyof Adjustments ;
label : string ;
max : number ;
}[] = [
{ key: "brightness" , label: "Brightness" , max: 200 },
{ key: "contrast" , label: "Contrast" , max: 200 },
{ key: "saturation" , label: "Saturation" , max: 200 },
{ key: "blur" , label: "Blur" , max: 10 },
];
export default function SliderRecipeImageAdjust () {
const [ values , setValues ] = useState < Adjustments >(defaults);
const filter = `brightness(${ values . brightness }%) contrast(${ values . contrast }%) saturate(${ values . saturation }%) blur(${ values . blur }px)` ;
return (
< div className = "grid w-full max-w-xl gap-5 sm:grid-cols-[1fr_14rem]" >
< div className = "aspect-[4/3] overflow-hidden rounded-xl border" >
< div
className = "size-full bg-[linear-gradient(160deg,#fde68a_0%,#f97316_35%,#be185d_65%,#312e81_100%)] transition-[filter]"
style = {{ filter }}
role = "img"
aria-label = "Sunset photo preview"
/>
</ div >
< div className = "flex flex-col gap-4" >
{controls. map (( c ) => (
< Slider
key = {c.key}
label = {c.label}
size = "sm"
color = "neutral"
maxValue = {c.max}
value = {values[c.key]}
onChange = {( v ) => setValues (( s ) => ({ ... s, [c.key]: v }))}
/>
))}
< Button
variant = "outline"
size = "sm"
className = "self-start"
onPress = {() => setValues (defaults)}
>
< RotateCcwIcon /> Reset
</ Button >
</ div >
</ div >
);
}
Two sliders feeding a live monthly estimate, one with marks and one formatted in gigabytes.
Estimate your bill $12 per seat, $0.25 per GB of storage.
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { Slider } from "@/components/ui/slider" ;
const PRICE_PER_SEAT = 12 ;
const PRICE_PER_GB = 0.25 ;
const usd = new Intl. NumberFormat ( "en-US" , {
style: "currency" ,
currency: "USD" ,
maximumFractionDigits: 0 ,
});
export default function SliderRecipePricingCalculator () {
const [ seats , setSeats ] = useState ( 25 );
const [ storage , setStorage ] = useState ( 500 );
const total = seats * PRICE_PER_SEAT + storage * PRICE_PER_GB ;
return (
< div className = "flex w-full max-w-sm flex-col gap-6 rounded-xl border bg-card p-6" >
< div >
< h3 className = "font-semibold" >Estimate your bill</ h3 >
< p className = "text-muted-foreground text-sm" >
{usd. format ( PRICE_PER_SEAT )} per seat, $0.25 per GB of storage.
</ p >
</ div >
< Slider
label = "Team members"
value = {seats}
onChange = {setSeats}
minValue = { 1 }
maxValue = { 200 }
marks = {[ 1 , 50 , 100 , 150 , 200 ]}
/>
< Slider
label = "Storage"
value = {storage}
onChange = {setStorage}
minValue = { 100 }
maxValue = { 5000 }
step = { 100 }
formatOptions = {{ style: "unit" , unit: "gigabyte" }}
/>
< div className = "flex items-end justify-between border-t pt-4" >
< div >
< p className = "text-muted-foreground text-xs" >Estimated monthly</ p >
< p className = "font-semibold text-2xl tabular-nums" >
{usd. format (total)}
</ p >
</ div >
< Button >Start free trial</ Button >
</ div >
</ div >
);
}
Each thumb contains a visually hidden <input type="range">, so screen readers announce it as a slider with its label, current value, minimum and maximum.
The announced value uses formatOptions (e.g. "80%", "€2,500"), and the visible output is an <output> element linked to the thumbs.
Without a label, pass aria-label or aria-labelledby.
In a range, each thumb input has its thumb label as aria-label, combined with the slider label through aria-labelledby, so screen readers can tell the thumbs apart. Override the defaults with thumbLabels.
Clicking anywhere on the track moves the nearest thumb there; thumbs can also be dragged with mouse, touch or pen.
In right-to-left locales the track direction and arrow keys are mirrored.
The focus ring only appears for keyboard focus (data-focus-visible).
Key Action Tab Moves focus to the next thumb → / ↑ Increases the value by one step ← / ↓ Decreases the value by one step Page Up / Page Down Increases / decreases by a larger step Home / End Sets the minimum / maximum value
On the Slider root:
Attribute Present when data-disabledisDisabled is truedata-orientation"horizontal" or "vertical"
On each thumb (style them from the root with arbitrary variants such as [&_[data-dragging]]:…):
Attribute Present when data-draggingBeing dragged data-hoveredHovered with a mouse or pen data-focused / data-focus-visibleFocused / focused with the keyboard data-disabledThe slider is disabled
data-slotElement sliderRoot labelThe label slider-trackThe track (click and drag target) slider-fillThe filled segment slider-thumbEach thumb
color sets --tone on the root. The filled track, the thumb border and its ring all use it, so a one-off color is a single class:
< Slider label = "Heat" className = "[--tone:var(--color-orange-500)]" defaultValue = { 70 } />
className accepts a function of the slider state (orientation, isDisabled, state):
< Slider className = {({ isDisabled }) => (isDisabled ? "grayscale" : "" )} />
Also accepts every prop of React Aria's Slider .