Slider
A draggable thumb for choosing a value or range along a track.
3,120 used so far in September.
import { Slider } from "@oration/canon/components/slider";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const id = React.useId(); const [budget, setBudget] = React.useState(4500); const used = 3120; return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-baseline justify-between gap-4"> <span id={`${id}-budget`} className="text-13 font-medium"> Monthly credit budget </span> <span className="text-13 tabular-nums"> <span className="font-medium"> {budget.toLocaleString("en-US")} </span> <span className="text-muted-foreground"> credits</span> </span> </div> <Slider aria-labelledby={`${id}-budget`} min={500} max={10000} step={250} value={[budget]} onValueChange={(value) => setBudget( Array.isArray(value) ? (value[0] ?? budget) : value, ) } onValueCommitted={(value) => { const next = Array.isArray(value) ? (value[0] ?? budget) : value; toast.add({ title: `Budget set to ${next.toLocaleString("en-US")} credits a month`, description: next < used ? "The AP agent has already used 3,120 and will pause until Nov 1." : "The AP agent pauses and asks for more if it reaches the limit.", }); }} /> <p className="text-13 text-muted-foreground tabular-nums"> 3,120 used so far in September. </p> </div> );}Usage#
Slider picks a value, or a range with two thumbs, by dragging along a 4px track. The filled part of the track is Quiet Indigo and each thumb is a 12px white circle with an indigo edge and a 28px hit area. It is built on Base UI Slider, so it handles pointer drag, arrow keys, Page and Home keys, and form submission. It has no label or readout of its own: pair it with a visible value, or use Slider field, and always pass value or defaultValue as an array.
When to use
- For a setting where the rough position matters more than the exact number: a daily sending limit, a monthly credit budget.
- For a bounded range filter with two thumbs, such as invoice amounts between $1,000 and $25,000.
- In a table row, to tune one value per item with the readout beside it.
- When the effect of the value can be shown live as it changes.
When not to use
- For a setting that needs a label, a readout, min and max hints and a reset. Slider field has all four. Use Slider field
- For an exact number, such as an invoice amount or a PO number. Type it. Use Input
- To show a read-only amount or progress. That is a meter, in ink. Use Meter
- For a duration in hours and minutes. Use Duration picker
- For a handful of named steps, such as Low, Medium and High. Use Segmented control
Show the value
The Ink Fill Rule
Anatomy#
- Track. 4px tall and fully round, in Well Gray (
--muted). - Range. The Quiet Indigo fill, from the start to the thumb or between two thumbs.
- Thumb. A 12px white circle with a 1px Focus Indigo edge. It sits inside the track's ends (
thumbAlignment="edge"), so it never overhangs the container. - Hit area. An invisible
::after8px past the thumb on every side, a 28px target. - Readout. Not part of the component: a 13px tabular value beside the label or at the end of the track.
Examples#
Single value
One thumb, a step, and the value in tabular figures at the end of the label row, updated as the thumb moves. Pass the value as a one-item array.
import { Slider } from "@oration/canon/components/slider";import * as React from "react";export function Single() { const id = React.useId(); const [limit, setLimit] = React.useState(40); return ( <div className="flex w-full max-w-sm flex-col gap-3"> <div className="flex items-baseline justify-between gap-4"> <span id={`${id}-limit`} className="text-13 font-medium"> Daily sending limit for ap@cedarline.io </span> <span className="text-13 tabular-nums">{limit} a day</span> </div> <Slider aria-labelledby={`${id}-limit`} min={5} max={100} step={5} value={[limit]} onValueChange={(value) => { const next = Array.isArray(value) ? value[0] : value; if (typeof next === "number") setLimit(next); }} /> </div> );}Range
Two values make two thumbs. minStepsBetweenValues keeps them apart, largeStep sets the Page key jump, and format makes screen readers announce dollars. The filter applies once, on release.
import { Slider } from "@oration/canon/components/slider";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Range() { const id = React.useId(); const [range, setRange] = React.useState<number[]>([1000, 25000]); const money = (value: number) => value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }); return ( <div className="flex w-full max-w-sm flex-col gap-3"> <div className="flex items-baseline justify-between gap-4"> <span id={`${id}-amount`} className="text-13 font-medium"> Invoice amount </span> <span className="text-13 tabular-nums"> {money(range[0] ?? 0)} to {money(range[1] ?? 0)} </span> </div> <Slider aria-labelledby={`${id}-amount`} min={0} max={50000} step={500} largeStep={5000} minStepsBetweenValues={2} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0, }} value={range} onValueChange={(value) => { if (Array.isArray(value)) setRange([...value]); }} onValueCommitted={() => toast.add({ title: "Filter applied", description: "Showing 86 invoices in that range.", }) } /> </div> );}In a table
One slider per row, named by an sr-only label through aria-labelledby, with its value at the end of the cell. The value turns amber when the limit is risky for that mailbox's health.
import { Slider } from "@oration/canon/components/slider";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function InTable() { const [mailboxes, setMailboxes] = React.useState([ { address: "ap@cedarline.io", health: 98, limit: 60 }, { address: "remittances@cedarline.io", health: 91, limit: 40 }, { address: "vendors@cedarline.io", health: 72, limit: 85 }, ]); return ( <div className="w-full max-w-xl overflow-hidden rounded-xl bg-card shadow-border"> <div className="grid grid-cols-[minmax(0,1fr)_4rem_minmax(0,14rem)] items-center gap-4 border-b border-border px-3 py-2 text-13 font-medium text-muted-foreground"> <span>Mailbox</span> <span className="text-right">Health</span> <span>Daily limit</span> </div> {mailboxes.map((mailbox) => { const labelId = `limit-${mailbox.address}`; const risky = mailbox.limit > 80 && mailbox.health < 80; return ( <div key={mailbox.address} className="grid grid-cols-[minmax(0,1fr)_4rem_minmax(0,14rem)] items-center gap-4 border-b border-border px-3 py-2.5 text-13 last:border-b-0" > <span className="truncate">{mailbox.address}</span> <span className="text-right tabular-nums"> {mailbox.health} </span> <div className="flex items-center gap-3"> <span id={labelId} className="sr-only"> Daily limit for {mailbox.address} </span> <Slider aria-labelledby={labelId} min={5} max={100} step={5} value={[mailbox.limit]} onValueChange={(value) => { const next = Array.isArray(value) ? value[0] : value; if (typeof next !== "number") return; setMailboxes((current) => current.map((item) => item.address === mailbox.address ? { ...item, limit: next } : item, ), ); }} className="flex-1" /> <span className={cn( "w-14 text-right text-xs tabular-nums", risky ? "text-warning" : "text-muted-foreground", )} > {mailbox.limit} a day </span> </div> </div> ); })} </div> );}Vertical
With orientation="vertical" the track stands up and fills the height of its container, at least 160px. Up and Right increase the value.
import { Slider } from "@oration/canon/components/slider";import * as React from "react";export function Vertical() { const id = React.useId(); const [threshold, setThreshold] = React.useState(70); return ( <div className="flex h-48 items-stretch gap-4"> <Slider aria-labelledby={`${id}-threshold`} orientation="vertical" min={0} max={100} step={5} value={[threshold]} onValueChange={(value) => { const next = Array.isArray(value) ? value[0] : value; if (typeof next === "number") setThreshold(next); }} className="w-auto" /> <div className="flex flex-col justify-end gap-1"> <span id={`${id}-threshold`} className="text-13 font-medium"> Match confidence </span> <span className="text-13 text-muted-foreground tabular-nums"> Auto-approve at {threshold}% or higher </span> </div> </div> );}Disabled
Dim the label row with the slider and say what would unlock it below.
Verify the domain cedarline.io to raise the limit.
import { Slider } from "@oration/canon/components/slider";import * as React from "react";export function Disabled() { const id = React.useId(); return ( <div className="flex w-full max-w-sm flex-col gap-3"> <div className="flex items-baseline justify-between gap-4 opacity-50"> <span id={`${id}-limit`} className="text-13 font-medium"> Daily sending limit </span> <span className="text-13 tabular-nums">40 a day</span> </div> <Slider aria-labelledby={`${id}-limit`} aria-describedby={`${id}-reason`} disabled min={5} max={100} defaultValue={[40]} /> <p id={`${id}-reason`} className="text-13 text-muted-foreground"> Verify the domain cedarline.io to raise the limit. </p> </div> );}States#
import { Slider } from "@oration/canon/components/slider";export function StatesRow() { const states = [ { name: "Rest", className: "", disabled: false }, { name: "Hover, focus or drag", className: "[&_[data-slot=slider-thumb]]:ring-3", disabled: false, }, { name: "Disabled", className: "", disabled: true }, ]; return ( <div className="grid w-full grid-cols-1 gap-8 sm:grid-cols-3"> {states.map((state) => ( <div key={state.name} className="flex flex-col gap-4"> <span className="text-xs text-muted-foreground"> {state.name} </span> <div inert> <Slider aria-label={state.name} value={[45]} disabled={state.disabled} className={state.className} /> </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Well Gray track, indigo range, white thumb with an indigo edge. |
| Hover | A 3px Focus Indigo ring at 50% around the thumb. |
| Focus visible | The same 3px ring, on keyboard focus. |
| Dragging | The ring stays while the thumb is held (active:), and the range follows the pointer. |
| Disabled | The whole control at 50% opacity, with no pointer events on the thumbs. |
Behavior#
- Controlled with
valueandonValueChange(value, eventDetails), or uncontrolled withdefaultValue. Pass arrays: the wrapper renders one thumb per array item. onValueCommittedfires once when the pointer is released or a key press ends, so save or toast there, not on every change.- Clicking the track moves the nearest thumb to that point and starts a drag.
stepsnaps values,largeStep(10 by default) is the jump for Page Up, Page Down and Shift with an arrow, andminStepsBetweenValueskeeps two thumbs apart.orientation="vertical"stands the track up, at least 160px tall.- Each thumb is a visually hidden
<input type="range">, sonamesubmits the value with a native form. - The ring on the thumb eases in with
transition-[color,box-shadow]at the default 150ms.
Do and don't#
onValueCommitted, when the thumb is released.onValueChange. A single drag fires dozens of saves.Content#
- Label the setting, not the gesture: Daily sending limit, not Drag to set limit.
- Write the value with its unit: 40 a day, 4,500 credits, $1,000 to $25,000.
- When the value changes the outcome, say how in a line below: Pauses the agent once 4,500 credits are used.
Accessibility#
- Name the slider with
aria-labelledbyon the Slider pointing at a visible label. Base UI passes it to each thumb's range input.aria-labelon the Slider does not reach the thumbs. - Screen readers read the value from
aria-valuenow; setformat(Intl number options) so it is announced with its unit, such as currency. - Keep the readout in
aria-live="polite"only if it adds information the value text doesn't, such as a warning. - The 12px thumb has a 28px hit area. In a dense row, leave room either side so the target doesn't overlap another control.
- Every value must be reachable by keyboard: step with arrows, jump with Page keys, and reach the ends with Home and End.
| Keys | Action |
|---|---|
| →↑ | Increases by step. |
| ←↓ | Decreases by step. |
| Shift→ | Increases by largeStep. Shift with ← or ↓ decreases. |
| Page Up | Increases by largeStep. |
| Page Down | Decreases by largeStep. |
| Home | Sets the minimum, or in a range, the lowest value the thumb can reach. |
| End | Sets the maximum, or in a range, the highest value the thumb can reach. |
Design tokens#
| Token | Used for |
|---|---|
--muted | The track |
--primary | The range fill |
--ring | Thumb edge, and the 3px ring at 50% on hover, focus and drag |
bg-white | Thumb fill, in both themes |
API reference#
Slider
Base UI Slider.Root with its Control, Track, Indicator and one Thumb per value. Renders data-slot="slider", and the track, range and thumbs carry slider-track, slider-range and slider-thumb.
Other props spread onto Base UI Slider.Root (<div role="group">).
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | readonly number[] | No default | Controlled value. Pass an array, one number per thumb. A plain number still renders two thumbs; see known gaps. |
defaultValue | number | readonly number[] | No default | Initial value when uncontrolled. Also pass an array. |
onValueChange | (value: number | number[], eventDetails: Slider.Root.ChangeEventDetails) => void | No default | Called on every change while dragging or stepping. |
onValueCommitted | (value: number | number[], eventDetails: Slider.Root.CommitEventDetails) => void | No default | Called once when the pointer is released or a key press ends. |
min | number | 0 | The smallest value. |
max | number | 100 | The largest value. |
step | number | 1 | The increment values snap to. |
largeStep | number | 10 | The jump for Page Up, Page Down and Shift with an arrow. |
minStepsBetweenValues | number | 0 | The closest two thumbs can get, in steps. |
thumbCollisionBehavior | "push" | "swap" | "none" | "push" | What happens when one thumb is dragged into another. |
orientation | "horizontal" | "vertical" | "horizontal" | Vertical tracks are at least 160px tall. |
thumbAlignment | "center" | "edge" | "edge-client-only" | "edge" | Where the thumb sits at the ends. The wrapper sets edge. |
format | Intl.NumberFormatOptions | No default | Formats the value for aria-valuetext. |
disabled | boolean | false | Dims to 50% and blocks interaction. |
name | string | No default | Form field name. |
aria-labelledby | string | No default | The id of the visible label. Passed through to every thumb. |
className | string | No default | Merged onto the root. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
A plain number for value or defaultValue renders two thumbs. The wrapper counts thumbs from the array, and without one it falls back to [min, max], while Base UI tracks a single value. Always pass an array.
Thumbs can't be named one by one, because the wrapper doesn't expose Base UI's getAriaLabel. Both thumbs of a range share the slider's label, with no Minimum and Maximum.
The thumb is hard-coded bg-white, so in dark it is the brightest thing on screen.
There is no built-in label or readout; product call sites hand-roll both (aria-labelledby on an sr-only span, a tabular value beside the track). Slider field covers the common case.