← ui

slider

ui

a slider with named marks to snap to and a drawing behind its track

npx shadcn add @ag/slider
Already installed? Preview an update
npx shadcn add @ag/slider --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • value is optional: give defaultValue and the slider keeps its own, saying each change (onValueChange); give value and whatever moves it outside moves the thumb, as before.
Updated 2026-10-14 · release notes

also called range slider

shelf controls

uses field-context, material, setting-context

used by components

the demo above, verbatim

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

slider.tsx
"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>
  )
}