a slider with named marks to snap to and a drawing behind its track
npx shadcn add @ag/sliderAlready installed? Preview an update
npx shadcn add @ag/slider --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
valueis optional: givedefaultValueand the slider keeps its own, saying each change (onValueChange); givevalueand whatever moves it outside moves the thumb, as before.
also called range slider
shelf controls
uses field-context, material, setting-context
used by components
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Setting } from "@/registry/base-nova/ui/setting"
import { Slider } from "@/registry/base-nova/ui/slider"
// #region curve
/** A made-up score over a minute, to cut with a threshold. Rounded: the server's
* and the browser's sine differ in the last digit, and the page would not hydrate. */
const SCORES = Array.from({ length: 120 }, (_, i) =>
Number(
Math.max(
0,
Math.min(1, 0.35 + 0.3 * Math.sin(i / 7) + 0.25 * Math.sin(i / 2.3)),
).toFixed(4),
),
)
// #endregion
function Choices() {
const [step, setStep] = React.useState(250)
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Slider
label="how often to read"
value={step}
onValueChange={setStep}
snap="marks"
marks={[2000, 1000, 500, 250, 125, 50].map((s) => ({
value: s,
label: `${1000 / s}/s`,
}))}
format={(s) => `${1000 / s} frames a second`}
/>
<span className="font-mono text-foreground/60 text-xs">
every {step} ms
</span>
</div>
)
}
function Threshold() {
const [cut, setCut] = React.useState(0.6)
const above = SCORES.filter((v) => v >= cut).length
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Slider
label="threshold"
value={cut}
onValueChange={setCut}
snap="near"
tone="oklch(0.74 0.19 350)"
marks={[0.25, 0.5, 0.75].map((v) => ({
value: v,
label: v.toFixed(2),
}))}
format={(v) => v.toFixed(2)}
behind={
<svg
aria-hidden
viewBox={`0 0 ${SCORES.length} 1`}
preserveAspectRatio="none"
className="size-full text-foreground/15"
>
{SCORES.map((v, i) => (
<rect
// biome-ignore lint/suspicious/noArrayIndexKey: a bar is its place on the axis
key={i}
x={i}
y={1 - v}
width={0.8}
height={v}
fill="currentColor"
/>
))}
</svg>
}
/>
<span className="font-mono text-foreground/60 text-xs">
{above} of {SCORES.length} at {cut.toFixed(2)} or above
</span>
</div>
)
}
// #region inherited
/** A level that falls back to the workspace's: a ghost of the value that applies
* (a hollow thumb, the fill faint) until it is set here; × (or ⌥-click, or Delete
* on the thumb) clears it back. */
function Inherited() {
const [volume, setVolume] = React.useState<number | undefined>(undefined)
return (
<Setting
label="volume"
value={volume}
fallback={{ value: 0.4, from: "the workspace" }}
onValueChange={setVolume}
format={(v) => `${Math.round(v * 100)}%`}
className="w-full"
>
{(s) => (
<Slider
label="volume"
value={s.value}
onValueChange={s.pin}
format={(v) => `${Math.round(v * 100)}%`}
className="min-w-0 flex-1"
/>
)}
</Setting>
)
}
// #endregion
export default function SliderDemo() {
return (
<div className="space-y-8">
<Sample name="choices" glance title="only the marks">
<Choices />
</Sample>
<Sample
name="threshold"
title="pulled onto marks, curve behind"
with="curve"
description="behind draws in the slider's own coordinates: here the scores the threshold cuts"
>
<Threshold />
</Sample>
<Sample name="glass" title="in glass">
<OnGlass />
</Sample>
<Sample name="inherited" title="following a default" with="inherited">
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Inherited />
</div>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6">
<Inherited />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="read-only"
title="read-only"
description="shown and read out, never moved: the thumb holds still under the hand and the keys, and keeps its focus. never dimmed, which is disabled."
>
<Slider
label="volume"
value={0.7}
onValueChange={() => {}}
format={(v) => `${Math.round(v * 100)}%`}
readOnly
/>
</Sample>
</div>
)
}
function OnGlass() {
const [volume, setVolume] = React.useState(0.6)
return (
<MaterialProvider material="glass">
<div className="rounded-2xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center px-10 py-12 text-white">
<Slider
label="volume"
value={volume}
onValueChange={setVolume}
marks={[
{ value: 0, label: "off" },
{ value: 1, label: "full" },
]}
format={(v) => `${Math.round(v * 100)}%`}
/>
</div>
</MaterialProvider>
)
}the source, every file an install writes
"use client"
// A slider on Base UI (keys, pointer, a label for screen readers) with snap points:
// places along it worth landing on, marked and named under the track.
//
// snap "marks" only the marks can be chosen: a list of choices on a line (how
// often to read: 0.5/s … 20/s), each a step of the keys
// snap "near" any value, pulled onto a mark when the hand comes within `pull`
// of it (a share of the range), so a round value is easy to hit
// snap "free" any value; the marks are only landmarks
//
// `behind` draws under the track, in the slider's own coordinates: a curve, a
// histogram, so where to put the thumb is read off what it cuts (a threshold over
// the scores it divides). Monochrome by `currentColor`: it sits on any surface;
// `tone` colours the fill and the thumb. Two materials (`material`): plain, a
// hairline and a solid knob; glass, a channel and a knob of the material's glass.
// `readOnly`: shown and read out, never moved. Inside a `Setting` whose value is
// inherited, a ghost: the thumb hollow, the fill at a third.
import { Slider as Base } from "@base-ui/react/slider"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useField } from "@/registry/base-nova/ui/field-context"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
export interface SliderMark {
value: number
/** Said under the track; absent, the mark is a tick only. */
label?: React.ReactNode
}
export interface SliderProps {
/** The value, owned outside: whatever moves it there moves the thumb. Without
* it the slider keeps its own, from `defaultValue` (as stepper does). */
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
/** When the hand lets go: for work that should not run on every move. */
onValueCommitted?: (value: number) => void
min?: number
max?: number
/** The keys' step, and the free value's grain (snap "near" and "free"). */
step?: number
marks?: readonly SliderMark[]
snap?: "marks" | "near" | "free"
/** How close counts as near a mark, as a share of the range (snap "near"). */
pull?: number
/** Drawn under the track, filling the slider's box. */
behind?: React.ReactNode
/** The fill's and the thumb's colour; the text colour otherwise. */
tone?: string
label: string
/** The value as words, for the reader and the thumb's title. */
format?: (value: number) => string
disabled?: boolean
/** Shown, never changed here: the thumb holds still under the hand and the keys,
* stays focusable and is read out. Never dimmed (that is `disabled`). A field's
* read-only and a locked setting's hold it the same way. */
readOnly?: boolean
/** What else says something about it (a hint's id); a field's and a setting's
* words are added to it. */
"aria-describedby"?: string
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}
/** Inherited (inside a `Setting`, nothing set here): a ghost of the value that
* applies, the thumb hollow in its tone and the fill at a third. Glass: the
* material's ghost on the thumb, its dot hollow. */
const GHOST_THUMB: Record<Material, string> = {
plain:
"bg-transparent shadow-none ring-[1.5px] ring-(--slider-tone,currentColor) ring-inset",
glass: GLASS_GHOST,
}
/** Each material's track and thumb. Plain: a hairline groove and a solid knob with a
* soft shadow under it, no halo round it. Glass: a channel of the material's glass
* and a knob of glass with the tone's own dot at its heart, its rim catching more
* light under the hand and while it is held. */
const FACE: Record<Material, { track: string; thumb: string }> = {
plain: {
track: "h-1 bg-current/15",
thumb:
"size-4 bg-(--slider-tone,currentColor) shadow-[0_1px_3px_rgb(0_0_0/0.45),0_0_0_0.5px_rgb(0_0_0/0.25)]",
},
glass: {
track: cn(
glassVariants({ shape: "surface" }),
"h-1.5 [--glass-blur:4px] [--glass-radius:3px]",
),
thumb: cn(
glassVariants({ shape: "surface" }),
"grid size-5 place-items-center [--glass-radius:10px] [--glass-rim:1.2] hover:[--glass-rim:1.6]! data-dragging:[--glass-rim:1.8]! [--glass-change-duration:200ms]",
),
},
}
export function Slider({
value: owned,
defaultValue,
onValueChange,
onValueCommitted,
min = 0,
max = 1,
step = 0.01,
marks = [],
snap = "free",
pull = 0.02,
behind,
tone,
label,
format = String,
disabled,
readOnly,
"aria-describedby": describedBy,
material,
scheme,
className,
}: SliderProps) {
const m = useMaterial(material)
// Over a picture it is read by the part of it under the slider.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
// Inside a field: described by it, and held still while it is read-only (a form
// sending: no change, the focus kept, never disabled). Inside a setting: a ghost
// while inherited, described by where its value comes from, held still while
// locked there.
const field = useField()
const setting = useSetting()
const ghost = setting?.inherited ?? false
const held =
(readOnly ?? false) ||
(field?.readOnly ?? false) ||
(setting?.readOnly ?? false)
if (owned === undefined && defaultValue === undefined)
throw new Error(
`slider ${label}: give a value (owned outside) or a defaultValue (its own)`,
)
const [own, setOwn] = React.useState(defaultValue as number)
const value = owned === undefined ? own : owned
const change = (v: number) => {
if (held) return
if (owned === undefined) setOwn(v)
onValueChange?.(v)
}
const described =
[describedBy ?? field?.wiring["aria-describedby"], setting?.says]
.filter(Boolean)
.join(" ") || undefined
const face = FACE[m]
if (snap === "marks" && marks.length < 2)
throw new Error(`slider ${label}: snap "marks" needs at least two marks`)
// Snapping to marks is an index slider underneath: equal steps between choices,
// however far apart their values are (0.5/s and 1/s get as much room as 8/s and
// 20/s), each choice a step of the keys, left to right in the order given (a
// step in ms runs the other way from the frames a second it reads as). A value
// slider sorts its marks by value.
const indexed = snap === "marks"
const sorted = React.useMemo(
() => (indexed ? [...marks] : [...marks].sort((a, b) => a.value - b.value)),
[marks, indexed],
)
const at = indexed
? Math.max(
0,
sorted.reduce(
(best, m, i) =>
Math.abs(m.value - value) <
Math.abs((sorted[best] as SliderMark).value - value)
? i
: best,
0,
),
)
: value
const toValue = (raw: number) => {
if (indexed) return (sorted[Math.round(raw)] as SliderMark).value
if (snap === "near") {
const near = sorted.find(
(m) => Math.abs(m.value - raw) <= pull * (max - min),
)
if (near) return near.value
}
return raw
}
const lo = indexed ? 0 : min
const hi = indexed ? sorted.length - 1 : max
const share = (v: number) => ((v - lo) / (hi - lo)) * 100
const markAt = (m: SliderMark, i: number) => share(indexed ? i : m.value)
return (
<Base.Root
ref={here}
data-slot="slider"
data-material={m}
data-inherited={ghost ? "" : undefined}
data-readonly={held ? "" : undefined}
value={at}
min={lo}
max={hi}
step={indexed ? 1 : step}
disabled={disabled ?? field?.wiring.disabled}
aria-label={label}
onValueChange={(v) => change(toValue(v as number))}
onValueCommitted={(v) =>
!held && onValueCommitted?.(toValue(v as number))
}
className={cn(
"flex w-full flex-col gap-1.5 data-disabled:opacity-40",
schemeOf(s),
className,
)}
style={
tone ? ({ "--slider-tone": tone } as React.CSSProperties) : undefined
}
>
<Base.Control
className={cn(
"relative flex h-6 w-full touch-none items-center",
held ? "cursor-default" : "cursor-pointer",
)}
>
{behind && (
<div className="pointer-events-none absolute inset-0">{behind}</div>
)}
<Base.Track className={cn("relative w-full rounded-full", face.track)}>
{/* The marks: stops set into the track itself, half its height, kept
inside its rounded ends. The ones the fill has reached are spent and
go under it, so the line reads as one line and what is left ahead
is what the dots say. */}
{sorted.map((m, i) =>
markAt(m, i) <= share(at) ? null : (
<span
key={m.value}
aria-hidden
data-slot="slider-mark"
className="pointer-events-none absolute top-1/2 size-0.5 -translate-1/2 rounded-full bg-current opacity-45"
style={{
left: `calc(var(--spacing) + (100% - 2 * var(--spacing)) * ${markAt(m, i) / 100})`,
}}
/>
),
)}
<Base.Indicator
className={cn(
"rounded-full bg-(--slider-tone,currentColor)",
ghost ? "opacity-35" : "opacity-80",
)}
/>
<Base.Thumb
data-slot="slider-thumb"
title={format(toValue(at))}
getAriaValueText={() => format(toValue(at))}
// Its description (a field's, a setting's) describes the thumb's input;
// read-only is said there too.
aria-describedby={described}
aria-readonly={held || undefined}
className={cn(
"rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current/50",
face.thumb,
ghost && GHOST_THUMB[m],
)}
>
{m === "glass" && (
<span
aria-hidden
className={cn(
"size-1.5 rounded-full",
ghost
? "ring-1 ring-(--slider-tone,currentColor)"
: "bg-(--slider-tone,currentColor)",
)}
/>
)}
</Base.Thumb>
</Base.Track>
</Base.Control>
{sorted.some((m) => m.label !== undefined) && (
<div
data-slot="slider-labels"
className="relative h-4 font-mono text-[10px] text-current/50 tabular-nums"
>
{sorted.map((m, i) =>
m.label === undefined ? null : (
<button
key={m.value}
type="button"
tabIndex={-1}
disabled={disabled}
onClick={() => {
if (held) return
change(m.value)
onValueCommitted?.(m.value)
}}
className={cn(
"absolute cursor-pointer whitespace-nowrap hover:text-current",
// Each label stands at its mark: centred on it, or, at the very
// ends of the track, hugging the edge so it is not cut off.
markAt(m, i) <= 2
? ""
: markAt(m, i) >= 98
? "-translate-x-full"
: "-translate-x-1/2",
toValue(at) === m.value && "text-current",
)}
style={{ left: `${markAt(m, i)}%` }}
>
{m.label}
</button>
),
)}
</div>
)}
</Base.Root>
)
}