Slider
A control for picking a number from a continuous scale. The accent indicator fills the sunken rail up to the thumb, signalling the selected value; a range adds a second thumb and fills between them.
Basic
Compose Slider.Root (which owns the value) with a Slider.Control holding the Slider.Track (and its Slider.Indicator) and a Slider.Thumb. Drive it with value / onValueChange, or omit them and pass defaultValue to let it own its state. Name a label-less slider with aria-label.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderBasic() {
const [value, setValue] = useState(40);
return (
<Slider.Root value={value} onValueChange={(next) => setValue(next as number)} aria-label="Volume" className="max-w-80">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
);
}
Label and value
Wrap Slider.Label and Slider.Value in a Slider.Header — a baseline space-between row above the control that owns the header→rail gap. The label names the slider for assistive tech (no separate aria-label needed) and the value renders the current number, localized and formatted by the Root's format options.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderLabelAndValue() {
const [value, setValue] = useState(0.6);
return (
<Slider.Root
value={value}
onValueChange={(next) => setValue(next as number)}
format={{ style: "percent" }}
max={1}
step={0.01}
className="max-w-80"
>
<Slider.Header>
<Slider.Label>Brightness</Slider.Label>
<Slider.Value />
</Slider.Header>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
);
}
Range
Pass a two-number array as the value and render one Slider.Thumb per value with an explicit index. The indicator fills the span between the two thumbs, and each thumb's value text gains a translated start/end suffix for screen readers. Use minStepsBetweenValues to keep a gap between the thumbs, and thumbCollisionBehavior (push · swap · none) to choose what happens when they meet.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderRange() {
const [value, setValue] = useState<number[]>([25, 75]);
return (
<Slider.Root
value={value}
onValueChange={(next) => setValue(next as number[])}
aria-label="Price range"
className="max-w-80"
>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb index={0} getAriaLabel={() => "Minimum price"} />
<Slider.Thumb index={1} getAriaLabel={() => "Maximum price"} />
</Slider.Control>
</Slider.Root>
);
}
Sizes
size sets the control height, rail thickness, and thumb diameter — sm or md (the default).
"use client";
import { Slider } from "@stridge/noctis";
export default function SliderSizes() {
return (
<div className="flex w-full max-w-80 flex-col gap-6">
<Slider.Root size="sm" defaultValue={40} aria-label="Small slider">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
<Slider.Root size="md" defaultValue={60} aria-label="Medium slider">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
</div>
);
}
Value on drag
Compose a Slider.ThumbValue inside a Slider.Thumb for a live readout that floats above the handle while dragging (and on keyboard focus) — so the value stays visible, never hidden under the finger. It is decorative (aria-hidden): the thumb's aria-valuetext remains the announced source of truth, so the value is never read twice.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderValueOnDrag() {
const [value, setValue] = useState(40);
return (
<Slider.Root value={value} onValueChange={(next) => setValue(next as number)} aria-label="Volume" className="max-w-80">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb>
<Slider.ThumbValue />
</Slider.Thumb>
</Slider.Control>
</Slider.Root>
);
}
Vertical
Set orientation="vertical" and give the slider a height; the control runs top-to-bottom and the indicator fills from the bottom up. Everything stays logical, so the layout mirrors correctly under RTL.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderVertical() {
const [value, setValue] = useState(60);
return (
<div className="flex h-48 w-full justify-center">
<Slider.Root
orientation="vertical"
value={value}
onValueChange={(next) => setValue(next as number)}
aria-label="Volume"
>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
</div>
);
}
Centre fill
By default the indicator fills from the minimum. Set origin to fill from a non-minimum anchor instead — the bipolar case where zero sits in the middle (balance, EQ, brightness ±). The fill spans origin → value in either direction. origin applies to a single-value slider; a range already fills between its two thumbs.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderFromCenter() {
const [value, setValue] = useState(20);
return (
<Slider.Root
value={value}
onValueChange={(next) => setValue(next as number)}
min={-50}
max={50}
origin={0}
className="max-w-80"
>
<Slider.Header>
<Slider.Label>Balance</Slider.Label>
<Slider.Value />
</Slider.Header>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb>
<Slider.ThumbValue />
</Slider.Thumb>
</Slider.Control>
</Slider.Root>
);
}
Marks
Layer a Slider.Marks over the control with a Slider.Mark per step to show a tick scale; pass a value to each mark and optional children for a caption (e.g. the min/max ends). A tick the value has passed turns the accent (the fill's signal); the rest read a neutral hairline. The whole layer is decorative (aria-hidden) — the value text already conveys position. Ticks help when an approximate choice on a labelled scale matters more than a precise number.
"use client";
import { Slider } from "@stridge/noctis";
import { useState } from "react";
const STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export default function SliderWithMarks() {
const [value, setValue] = useState(6);
return (
// The captions sit just below the control, so reserve a little room beneath the rail.
<div className="w-full max-w-80 pb-6">
<Slider.Root
value={value}
onValueChange={(next) => setValue(next as number)}
min={0}
max={10}
step={1}
aria-label="Rating"
>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Marks>
{STEPS.map((step) => (
<Slider.Mark key={step} value={step}>
{step === 0 ? "Poor" : step === 10 ? "Great" : undefined}
</Slider.Mark>
))}
</Slider.Marks>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
</div>
);
}
Validation
Inside a form, reflect an error with aria-invalid (and point aria-describedby at the message): the track border and thumb ring turn danger while the accent fill keeps its meaning. A slider wrapped in a Base UI Field.Root picks up the same cue automatically from the field's data-invalid.
"use client";
import { Button, Slider } from "@stridge/noctis";
import { useState } from "react";
export default function SliderInvalid() {
const [value, setValue] = useState(0);
const [submitted, setSubmitted] = useState(false);
const invalid = submitted && value < 10;
return (
<form
className="flex w-full max-w-80 flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
<Slider.Root
value={value}
onValueChange={(next) => setValue(next as number)}
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "budget-error" : undefined}
>
<Slider.Header>
<Slider.Label>Monthly budget</Slider.Label>
<Slider.Value />
</Slider.Header>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
{invalid ? (
<p id="budget-error" className="text-small text-danger">
Set a budget of at least $10 to continue.
</p>
) : null}
<Button type="submit" size="sm" className="self-start">
Save
</Button>
</form>
);
}
Keyboard
The thumb is fully keyboard-operable. Arrow keys mirror under RTL, so the value always moves toward the reading direction.
| Key | Action |
|---|---|
| Tab / Shift + Tab | Move focus onto / off each thumb (the tab order stays constant regardless of visual position). |
| ← / → / ↑ / ↓ | Move the value by one step. Left/right mirror under RTL. |
| Shift + ← / → / ↑ / ↓ | Move the value by largeStep (default 10). |
| Page Up / Page Down | Move the value by largeStep. |
| Home / End | Jump to the minimum / maximum. |
Anatomy
Compose the slider from its parts. Slider.Root is a Base UI Slider, so value handling, dragging, keyboard operation, and localization come for free.
Slider.Root— owns the value (value/onValueChange, or uncontrolleddefaultValue) and the sharedsize. Pass a number for a single value or a two-number array for a range; set the scale withmin/max/stepand the value formatting withformat. Tune the keyboard withlargeStepand the range withminStepsBetweenValues/thumbCollisionBehavior; commit on release (for expensive updates) withonValueCommitted; anchor a bipolar fill withorigin. Disable it withdisabled.Slider.Header— an optional baseline row above the control holding theSlider.Label+Slider.Value.Slider.Label/Slider.Value— the accessible label and the formatted current value.Slider.Control— the interactive row the rail and thumbs sit centred within.Slider.Track— the sunken rail; holds theSlider.Indicator.Slider.Indicator— the accent fill up to the thumb (or between range thumbs, or from a non-minorigin).Slider.Thumb— a draggable handle; render one per value with anindex. Base UI stampsdata-indexon each, a hook for styling the two ends of a range differently.Slider.ThumbValue— an opt-in live readout, composed inside aSlider.Thumb.Slider.Marks/Slider.Mark— an optional tick scale layered over the control.
Every rendered part carries a data-slot (noctis-slider on the root, then noctis-slider-header · noctis-slider-label · noctis-slider-value · noctis-slider-control · noctis-slider-track · noctis-slider-indicator · noctis-slider-thumb · noctis-slider-thumb-value · noctis-slider-marks · noctis-slider-mark · noctis-slider-mark-label) for host-side styling — pair it with the Base UI state attributes (data-orientation, data-dragging, data-disabled, data-focused, data-index, and the field-state data-invalid / data-valid / data-dirty / data-touched).
Accessibility
- Each thumb is a
sliderwitharia-valuenow/min/maxand localizedaria-valuetext; for a range, the dependent min/max keep the thumbs from crossing and the tab order stays constant. Give every thumb its own label (getAriaLabel), especially in a range. - The drag readout (
Slider.ThumbValue) and the tick captions (Slider.Mark) arearia-hiddendecoration — the value text stays the single announced source, so nothing is read twice. - Focus shows a
:focus-visiblebox-shadow ring that hugs the round thumb; the grow-on-drag and the readout's slide-in respectprefers-reduced-motion(the readout stays, only its motion drops).
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every slider in that region retunes — e.g. .compact { --noctis-slider-thumb-size: 0.875rem; } shrinks the handles beneath it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; the Root forwards the Base UI Slider props it owns the value through. Expand a row for the full type and description.