Skip to content
Monogem

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 --primary fill 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

Volume50

Stepped — 50-unit step, formatted `aria-valuetext`

Max price$400

Off-step value + a changing `step` — fill tracks the effective grid value

Level — raw 35, effective 40step 20

Uncontrolled

Disabled

Code Example

slider-showcase.tsx
"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:

PropEffect
min / maxThe range bounds (default 0 / 100).
stepThe 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.
formatValueMaps 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 + onValueChangeControlled.
defaultValueUncontrolled initial value.
nameEmitted 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

StateLook
Default--muted track, --primary fill to the thumb, --primary-bordered thumb on --background.
HoverPointer 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.
DraggingThe thumb follows the pointer; the value snaps to step. Pointer capture keeps the drag alive if the pointer leaves the track.
DisabledNative 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.

TokenWhere used
--mutedTrack (unfilled)
--primaryFilled range + thumb border
--backgroundThumb fill
--ringFocus-visible outline on the thumb
--radius-fullTrack and thumb shape
--scale-4Thumb diameter (16px) + the input's hit-target height
--scale-2Painted track height (8px), same as Progress
--scale-0-5Thumb border width, focus-outline width, focus-outline offset

Do / Don’t

Do

  • Give every Slider an accessible name — aria-label or aria-labelledby pointing 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 formatValue when the number needs units to make sense ("$400", "60%", "1.5×").
  • Use a coarse step for 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-valuetext when 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, and aria-valuenow come from min / max / value for free. aria-valuetext is added by the component only when formatValue is 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:

    KeyAction
    ← / ↓Decrease by step
    → / ↑Increase by step
    HomeJump to min
    EndJump to max
    Page 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, and touch-action is handled by the platform.

  • Disabled — native disabled removes 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.

Related Components / Patterns

  • Progress — the same track-and-fill language, for display.
  • Input — the typed alternative for an exact value.