Skip to content

Slider

A draggable thumb for choosing a value or range along a track.

Status
Stable
Level
Atom
Category
Inputs
Adoption
Not used yet
import { Slider } from "@oration/canon/components/slider";
packages/canon/src/components/slider.tsx
Monthly credit budget4,500 credits

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

A slider always travels with its current value in text, in tabular figures, next to its label or at the end of the track. People shouldn't have to guess what the thumb position means.

The Ink Fill Rule

The indigo fill belongs to the control you drag. When the bar only reports a quantity, such as credits used, draw it as a Meter in ink steps instead.

Anatomy#

60 a day
  1. Track. 4px tall and fully round, in Well Gray (--muted).
  2. Range. The Quiet Indigo fill, from the start to the thumb or between two thumbs.
  3. 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.
  4. Hit area. An invisible ::after 8px past the thumb on every side, a 28px target.
  5. 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.

Daily sending limit for ap@cedarline.io40 a day
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.

Invoice amount$1,000 to $25,000
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.

MailboxHealthDaily limit
ap@cedarline.io98
Daily limit for ap@cedarline.io
60 a day
remittances@cedarline.io91
Daily limit for remittances@cedarline.io
40 a day
vendors@cedarline.io72
Daily limit for vendors@cedarline.io
85 a day
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.

Match confidenceAuto-approve at 70% or higher
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.

Daily sending limit40 a day

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#

Rest
Hover, focus or drag
Disabled
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>    );}
States
StateTreatment
RestWell Gray track, indigo range, white thumb with an indigo edge.
HoverA 3px Focus Indigo ring at 50% around the thumb.
Focus visibleThe same 3px ring, on keyboard focus.
DraggingThe ring stays while the thumb is held (active:), and the range follows the pointer.
DisabledThe whole control at 50% opacity, with no pointer events on the thumbs.

Behavior#

  • Controlled with value and onValueChange(value, eventDetails), or uncontrolled with defaultValue. Pass arrays: the wrapper renders one thumb per array item.
  • onValueCommitted fires 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.
  • step snaps values, largeStep (10 by default) is the jump for Page Up, Page Down and Shift with an arrow, and minStepsBetweenValues keeps two thumbs apart.
  • orientation="vertical" stands the track up, at least 160px tall.
  • Each thumb is a visually hidden <input type="range">, so name submits 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#

Daily sending limit40 a day
Do. Show the value beside the slider and update it as the thumb moves.
Daily sending limit
Don't. Leave a bare track. Nobody can tell whether the thumb means 40 or 60 emails a day.
Do. Save once in onValueCommitted, when the thumb is released.
Don't. Save or toast in 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-labelledby on the Slider pointing at a visible label. Base UI passes it to each thumb's range input. aria-label on the Slider does not reach the thumbs.
  • Screen readers read the value from aria-valuenow; set format (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.
Keyboard interactions
KeysAction
→↑Increases by step.
←↓Decreases by step.
Shift→Increases by largeStep. Shift with ← or ↓ decreases.
Page UpIncreases by largeStep.
Page DownDecreases by largeStep.
HomeSets the minimum, or in a range, the lowest value the thumb can reach.
EndSets the maximum, or in a range, the highest value the thumb can reach.

Design tokens#

Design tokens
TokenUsed for
--mutedThe track
--primaryThe range fill
--ringThumb edge, and the 3px ring at 50% on hover, focus and drag
bg-whiteThumb 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">).

Props of Slider
PropTypeDefaultDescription
valuenumber | readonly number[]No defaultControlled value. Pass an array, one number per thumb. A plain number still renders two thumbs; see known gaps.
defaultValuenumber | readonly number[]No defaultInitial value when uncontrolled. Also pass an array.
onValueChange(value: number | number[], eventDetails: Slider.Root.ChangeEventDetails) => voidNo defaultCalled on every change while dragging or stepping.
onValueCommitted(value: number | number[], eventDetails: Slider.Root.CommitEventDetails) => voidNo defaultCalled once when the pointer is released or a key press ends.
minnumber0The smallest value.
maxnumber100The largest value.
stepnumber1The increment values snap to.
largeStepnumber10The jump for Page Up, Page Down and Shift with an arrow.
minStepsBetweenValuesnumber0The 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.
formatIntl.NumberFormatOptionsNo defaultFormats the value for aria-valuetext.
disabledbooleanfalseDims to 50% and blocks interaction.
namestringNo defaultForm field name.
aria-labelledbystringNo defaultThe id of the visible label. Passed through to every thumb.
classNamestringNo defaultMerged 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.