Slider
Select a value or range by dragging a thumb.
Volume50
Price range$25 – $75
Committed: (drag and release)
Codeapp/demos/public/slider.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { Slider } from '@stemsui/registry/ui/slider.tsx'
export const SliderDemo = clientEntry(import.meta.url, function SliderDemo(handle: Handle) {
let volume = 50
let range: number[] = [25, 75]
let committed: string | null = null
let submitted: string | null = null
return () => (
<div class="flex w-full max-w-sm flex-col gap-8">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between text-sm font-medium">
<span id="volume-label">Volume</span>
<span class="text-muted-foreground tabular-nums" data-testid="volume-value">
{volume}
</span>
</div>
<Slider
aria-labelledby="volume-label"
value={volume}
onValueChange={(value) => {
volume = value as number
void handle.update()
}}
/>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between text-sm font-medium">
<span>Price range</span>
<span class="text-muted-foreground tabular-nums" data-testid="range-value">
${range[0]} – ${range[1]}
</span>
</div>
<Slider
value={range}
step={5}
minStepsBetweenValues={2}
getAriaLabel={(index) => (index === 0 ? 'Minimum price' : 'Maximum price')}
format={{ style: 'currency', currency: 'USD', maximumFractionDigits: 0 }}
onValueChange={(value) => {
range = value as number[]
void handle.update()
}}
onValueCommitted={(value) => {
committed = (value as number[]).join(', ')
void handle.update()
}}
/>
<p class="text-xs text-muted-foreground" data-testid="range-committed">
Committed: {committed ?? '(drag and release)'}
</p>
</div>
<div class="flex h-40 items-stretch gap-8 px-2">
<Slider aria-label="Bass" orientation="vertical" defaultValue={30} />
<Slider aria-label="Treble" orientation="vertical" defaultValue={70} />
<Slider aria-label="Disabled" orientation="vertical" defaultValue={50} disabled />
</div>
<form
class="flex flex-col gap-3 rounded-lg border p-4"
mix={on('submit', (event) => {
event.preventDefault()
let entries = [...new FormData(event.currentTarget).entries()]
submitted = entries.map(([key, value]) => `${key}=${String(value)}`).join('&')
void handle.update()
})}
>
<span class="text-sm font-medium" id="brightness-label">
Brightness
</span>
<Slider aria-labelledby="brightness-label" name="brightness" defaultValue={[20, 80]} />
<button
type="submit"
class="inline-flex h-8 items-center justify-center self-start rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Save
</button>
<p class="text-sm text-muted-foreground" data-testid="slider-form-result">
Submitted: <code class="font-mono text-foreground">{submitted ?? '(not yet)'}</code>
</p>
</form>
</div>
)
})
Installation
npx shadcn add @stemsui/sliderRequires the one-time setup. Behavior: stems-ui/slider.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/slider
slider lets the user pick a value, or a range of values, by dragging thumbs along a track.
Base UI equivalent: Slider.
Usage
import * as slider from 'stems-ui/slider'
function PriceRange() {
return () => (
<slider.Context defaultValue={[20, 80]} name="price" minStepsBetweenValues={5}>
<div mix={slider.root()}>
<span mix={slider.label()}>Price</span>
<output mix={slider.value()}>
<slider.ValueText />
</output>
<div class="relative flex touch-none items-center py-3" mix={slider.control()}>
<div class="h-1 w-full bg-muted" mix={slider.track()}>
<div class="h-full bg-primary" mix={slider.indicator()} />
</div>
<span
class="size-4 rounded-full bg-white"
aria-label="Minimum price"
mix={slider.thumb({ index: 0 })}
/>
<span
class="size-4 rounded-full bg-white"
aria-label="Maximum price"
mix={slider.thumb({ index: 1 })}
/>
</div>
</div>
</slider.Context>
)
}
stems-ui/slider
<slider.Context>props (Base UI'sSlider.Rootprops):value/defaultValue:number | number[]. An array makes a range slider with one thumb per value.defaultValuedefaults tomin.onValueChange(value, details):detailshasreason('track-press' | 'drag' | 'keyboard'),event,trigger,activeThumbIndex,cancel(), andisCanceled.onValueCommitted(value, { reason, event }): fires on pointer release (only when the value changed) and after each keyboard step.min(0),max(100),step(1, withminas the origin),largeStep(10),minStepsBetweenValues(0).orientation:'horizontal'(default) or'vertical'.thumbAlignment:'center'(default) or'edge'('edge-client-only'is treated as'edge').thumbCollisionBehavior:'push'(default),'swap', or'none'.disabled,name,form,format(Intl.NumberFormatOptions),locale.
slider.root():role="group",aria-labelledbypointing atlabel()when there is one.slider.label(): sets theidthat the root and thumbs point at. Clicking it focuses the first enabled thumb.slider.value(): the host for the value text (use an<output>). Setsaria-live="off"andfor(the thumb ids).<slider.ValueText>: renders the formatted values, joined with an en dash for ranges, as a text node. Pass a function child to customize it:{(formattedValues, values) => …}.slider.control(): the interactive area. Handles pointer presses and drags.slider.track(): the track. Defaults tostyle="position: relative"unless the host setsstyle.slider.indicator(): the filled range. Owns its inline position and size.slider.thumb(options?): a focusablerole="slider"element. It setsaria-valuenow,aria-valuemin,aria-valuemax,aria-valuetext,aria-orientation, andtabindex, and it owns its inline position. Options:index: the index of the thumb's value. Without it, thumbs are numbered in render order. Pass it when thumbs render conditionally or out of order.disabled: disables this thumb only.getAriaLabel(index),getAriaValueText(formattedValue, value, index).
- Events:
stems:slider-change(SliderChangeEvent:value,reason,activeThumbIndex) andstems:slider-commit(SliderCommitEvent:value,reason). Both bubble from the control. Listen withslider.onSliderChange(handler)andslider.onSliderCommit(handler).
Behavior notes
- Thumbs are
role="slider"elements, not nested<input type="range">. Base UI nests a visually hidden range input in each thumb. A Remix mixin can't add children to its host, so Stems follows the WAI-ARIA slider pattern. Because of this, the'input-change'change reason never occurs, and the thumbs aren't labelable with<label for>(uselabel(),aria-label, oraria-labelledby). Browsers turn assistive-technology increment and decrement actions into arrow-key events for ARIA sliders. - Form participation: when
nameis set, the Context renders one<input type="hidden">per thumb after its children. For a range slider, read the values withFormData.getAll(name). - Keyboard (on a thumb):
- ArrowRight and ArrowUp increase the value. ArrowLeft and ArrowDown decrease it.
- In RTL horizontal sliders, ArrowLeft increases the value and ArrowRight decreases it.
- Shift+arrow and PageUp/PageDown step by
largeStep. - Home and End go to
minandmax. In a range slider, they stop at the neighbor thumb. - Keyboard steps never pass the neighbor thumb (minus
minStepsBetweenValues).
- Pointer:
- Pressing the control moves the closest thumb to the press. If thumbs overlap, a press above them picks the last thumb and a press below picks the first, so they can be pulled apart.
- Pressing a thumb grabs it without making it jump.
- Dragging uses pointer capture on the control and sets
data-draggingafter the first move. Focus moves to the dragged thumb.
- Collisions during drags follow
thumbCollisionBehavior:'push'moves neighbor thumbs along.'swap'lets the thumb pass its neighbor; focus follows the moved thumb.'none'stops the thumb at its neighbor.
- Positioning is pure CSS, so it is correct in server HTML:
centeralignment:inset-inline-start: P%(vertical sliders:bottom: P%) withtranslate: -50% -50%.edgealignment translates the thumb by-P%of its own size. This keeps it inside the control without measuring it.- The control must be the thumbs' containing block (
position: relative). Give ittouch-action: noneso touch drags don't scroll the page.
- RTL: server HTML assumes LTR. Each thumb checks its direction once mounted, and the thumbs re-render if the direction is RTL.
- Formatting:
- The default
aria-valuetextfollows Base UI:"<value> start range"and"<value> end range"for range sliders, and the formatted value whenformatis set. - Formatting uses the runtime locale on both the server and the client. Pass
localewhen the two may differ.
- The default
- Labels:
- Thumbs rendered after
label()getaria-labelledbyin server HTML. - The root, and any parts rendered before
label(), get it after hydration.
- Thumbs rendered after
- Field: inside
<field.Context>the slider is the field's control. Its value (a number, or an array for a range) is the field value (validatereceives it); the field'snamewins for the hidden inputs and its (or an enclosingfieldset.Context's)disableddisables the slider. Thumbs getaria-labelledby(the slider'slabel(), else the field label),aria-describedby(the field's descriptions and visible errors) andaria-invalid; the root gets the field's control id, so afield.label()click focuses the first enabled thumb, which is also focused when the field blocks a submission. Moving focus between thumbs doesn't blur the field. Like Base UI, there is norequired, so the slider has no native validity element: validate withvalidate.
Styling hooks
All parts (root, label, value, control, track, indicator, thumb):
| Attribute | Values |
|---|---|
data-orientation | horizontal or vertical. |
data-dragging | Present while a thumb is being dragged. |
data-disabled | Present when the slider (or thumb) is disabled. |
data-valid / data-invalid | In a field: validated and valid / invalid. |
data-dirty | In a field: the value changed from the initial value. |
data-touched | In a field: focus left the slider once. |
data-focused | In a field: a thumb has focus. |
thumb also has data-index (its value index). The active thumb (the last one focused or
moved) gets z-index: 1. No CSS variables are set.