Component · Composite & Advanced
Slider
Overview
A single horizontal control for choosing one value from a continuous or evenly-stepped range, where seeing the value's position within the range matters as much as the value itself — volume, brightness, a price ceiling, an opacity.
Reach for something else when the range doesn't help the user:
- A precise number the user already knows (age, quantity, a port) → Input
type="number". A slider makes an exact value harder to hit. - A small set of discrete, named choices → Radio Group or Select.
- An on/off setting → Switch.
Anatomy
Track (--muted, --scale-2 / 8px tall, --radius-full) → filled range (--primary, from the
minimum to the thumb) → thumb (--scale-4 / 16px circle, --background fill, --scale-0-5
--primary border).
- Track — the muted rail.
- Filled range — the
--primaryfill from the minimum to the thumb. - Thumb — the circular handle.
The filled range is the native ::-moz-range-progress in Firefox; WebKit / Blink has no such
pseudo-element, so there the fill is a left-anchored --primary gradient over the --muted
track, sized by a --slider-fill percentage the component sets from the current value. Both
render identically.
Live Example
Default — with value readout
Stepped — 50-unit step, formatted `aria-valuetext`
Off-step value + a changing `step` — fill tracks the effective grid value
Uncontrolled
Disabled
Code Example
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Slider, snapToStep } from "@/components/ui/slider";
/**
* Live Slider example — a browser-native `<input type="range">` with Monogem's
* scoped visual treatment. `"use client"` so the demos can hold value state and
* show a live readout. Each slider carries an accessible name via
* `aria-labelledby` pointing at a visible `<Label>` — a slider with no name is
* a review finding, the same rule as Progress. Shows a default slider with a
* value readout, a stepped slider with `formatValue` feeding `aria-valuetext`,
* a raw off-step value handed straight to the component (with a live `step`
* toggle, so the snap survives a configuration change), the uncontrolled form,
* and the disabled state.
*/
export function SliderShowcase() {
const [volume, setVolume] = React.useState(50);
const [price, setPrice] = React.useState(400);
// Raw, un-normalised: state holds exactly what the caller set (35). The
// component resolves it to the effective grid value for the fill / readout,
// and re-resolves it when `step` changes below — no pre-snapping here.
const [level, setLevel] = React.useState(35);
const [levelStep, setLevelStep] = React.useState(20);
const effectiveLevel = snapToStep(level, 0, 100, levelStep);
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Default — with value readout
</h3>
<div className="flex max-w-sm flex-col gap-1.5">
<span
id="slider-volume"
className="flex justify-between text-xs leading-snug text-muted-foreground"
>
<span>Volume</span>
<span>{volume}</span>
</span>
<Slider
aria-labelledby="slider-volume"
value={volume}
onValueChange={setVolume}
/>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Stepped — 50-unit step, formatted `aria-valuetext`
</h3>
<div className="flex max-w-sm flex-col gap-1.5">
<span
id="slider-price"
className="flex justify-between text-xs leading-snug text-muted-foreground"
>
<span>Max price</span>
<span>${price}</span>
</span>
<Slider
aria-labelledby="slider-price"
min={0}
max={1000}
step={50}
value={price}
onValueChange={setPrice}
formatValue={(v) => `$${v}`}
/>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Off-step value + a changing `step` — fill tracks the effective grid
value
</h3>
<div className="flex max-w-sm flex-col gap-2">
<span
id="slider-level"
className="flex justify-between text-xs leading-snug text-muted-foreground"
>
<span>Level — raw {level}, effective {effectiveLevel}</span>
<span>step {levelStep}</span>
</span>
<Slider
aria-labelledby="slider-level"
min={0}
max={100}
step={levelStep}
value={level}
onValueChange={setLevel}
/>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() =>
setLevelStep((current) => (current === 20 ? 25 : 20))
}
>
Toggle step (20 / 25)
</Button>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Uncontrolled
</h3>
<div className="max-w-sm">
<Label htmlFor="slider-brightness" className="sr-only">
Brightness
</Label>
<Slider id="slider-brightness" defaultValue={70} />
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Disabled
</h3>
<div className="max-w-sm">
<Slider aria-label="Contrast (locked)" defaultValue={30} disabled />
</div>
</section>
</div>
);
}Variants
One shape only — a single horizontal value slider. What varies is configuration, not appearance:
| Prop | Effect |
|---|---|
min / max | The range bounds (default 0 / 100). |
step | The smallest increment (default 1). A coarse step (e.g. 50) turns the slider into an evenly-spaced stepped control; keyboard and drag both snap to it. An initial value / defaultValue that isn't on the grid is resolved to the nearest step from min (ties round up) — the same value the native control reports — so the fill and the aria-valuetext announcement match what the user will actually get. snapToStep(value, min, max, step) is exported for a caller that wants to seed controlled state with that resolved value. |
formatValue | Maps the raw number to a spoken string for aria-valuetext (e.g. v => `$${v}`). Use whenever the bare number isn't self-explanatory — currency, percentages, durations. |
value + onValueChange | Controlled. |
defaultValue | Uncontrolled initial value. |
name | Emitted for plain <form> submission, straight from the native element. |
Not in V1: two-thumb range, vertical orientation, tick marks, an inline value tooltip on the thumb, right-to-left layout.
States
| State | Look |
|---|---|
| Default | --muted track, --primary fill to the thumb, --primary-bordered thumb on --background. |
| Hover | Pointer cursor over the whole control. No colour change — the track is already an obvious target, the same reasoning Switch uses. |
| Focus-visible | --ring outline on the thumb (--scale-0-5 wide, --scale-0-5 offset), :focus-visible only — keyboard focus, not a pointer grab. The native input's own outline is suppressed so only the thumb ring shows. |
| Dragging | The thumb follows the pointer; the value snaps to step. Pointer capture keeps the drag alive if the pointer leaves the track. |
| Disabled | Native disabled (removed from the tab order, no pointer events) + opacity-50 (Color — Disabled dimming) + not-allowed cursor. |
Usage Guidance
Tokens
Slider is built on a browser-native <input type="range">, so keyboard, pointer and touch dragging, aria-value*, form submission, and disabled all come from the platform. A small scoped style block themes the range track and thumb with existing Monogem semantic and primitive tokens only — no new tokens, and no behavior layered on top of the native element.
| Token | Where used |
|---|---|
--muted | Track (unfilled) |
--primary | Filled range + thumb border |
--background | Thumb fill |
--ring | Focus-visible outline on the thumb |
--radius-full | Track and thumb shape |
--scale-4 | Thumb diameter (16px) + the input's hit-target height |
--scale-2 | Painted track height (8px), same as Progress |
--scale-0-5 | Thumb border width, focus-outline width, focus-outline offset |
Do / Don’t
Do
- Give every Slider an accessible name —
aria-labeloraria-labelledbypointing at a visible Label. A slider with no name is a review finding, the same rule as Progress. - Show the current value near the slider (a caller-rendered readout) whenever the user needs to know the exact number, not just the approximate position.
- Pass
formatValuewhen the number needs units to make sense ("$400", "60%", "1.5×"). - Use a coarse
stepfor a stepped choice, so keyboard and drag land on the same values.
Don’t
- Use a Slider for a value the user must enter precisely — that's an Input.
- Use a Slider for a handful of named options — that's Radio Group or Select.
- Rely on the fill colour alone to communicate the value — pair it with a readout or
aria-valuetextwhen the value matters. - Add a two-thumb range, a vertical track, or tick marks by reaching around the component — those are deferred, not supported.
Accessibility
The native <input type="range"> is the whole interaction and accessibility contract:
-
Value semantics —
aria-valuemin,aria-valuemax, andaria-valuenowcome frommin/max/valuefor free.aria-valuetextis added by the component only whenformatValueis supplied, so assistive tech announces "$400", not "400". -
Name — supplied by the caller (
aria-label/aria-labelledby), applied via{...props}, which is spread before the component's own attributes so the value wiring set afterward can't be half-broken. -
Keyboard — provided by the browser, not reimplemented:
Key Action ←/↓Decrease by step→/↑Increase by stepHomeJump to minEndJump to maxPage Down/Page UpDecrease / increase by the browser's larger increment -
Pointer & touch — native: click or tap the track to move the thumb there, then drag. The thumb hit target is
--scale-4(16px) tall within a full-width control, andtouch-actionis handled by the platform. -
Disabled — native
disabledremoves the control from the tab order automatically.
Custom, non-native slider implementations are outside Monogem's baseline. A slider built on a
<div role="slider"> has to re-create the value semantics, the full keyboard map, pointer capture,
and touch handling by hand and keep them in sync — the same caveat Switch
makes about non-native controls. The native range input above is the only supported baseline.
Responsive Behavior
The control is fluid — width: 100% of its container — and has no intrinsic breakpoint. Place it
in a constrained column (a settings form, a filter panel) and it fills that column at every size.
The thumb and track heights are fixed in --scale-* units and don't change with the viewport.