Skip to content

Thumb swells on grab, value rides above it, pressed track glides.

Sliders & dials@base-ui/react

01Preview

Autoscaling

api-gateway
Max instances
Scale up above CPU
Memory per instance

Memory is fixed on the Hobby plan.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react

03Usage

import { Slider } from "@/components/ui/slider";

<Slider label="Opacity" showValue defaultValue={80} formatValue={(v) => `${v}%`} />

// Stepped, with labeled marks you can press to jump to.
<Slider
  label="Max instances"
  min={1}
  max={20}
  marks={[1, 5, 10, 15, 20].map((v) => ({ value: v, label: v }))}
  onValueCommitted={save}
/>

04Source

"use client";
import { Slider as BaseSlider } from "@base-ui/react/slider";
import { useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { cn } from "@/lib/cn";
import { useControllableState } from "@/lib/use-controllable-state";

type Size = "sm" | "md" | "lg";
export type SliderMark = number | { value: number; label?: ReactNode };

export type SliderProps = Omit<
  ComponentProps<typeof BaseSlider.Root>,
  "value" | "defaultValue" | "onValueChange" | "onValueCommitted" | "children" | "className" | "render" | "largeStep" | "orientation" | "thumbAlignment"
> & {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Fires once when a drag ends, a key is released or the track is pressed. Save here, not on every change. */
  onValueCommitted?: (value: number) => void;
  /** Visible label above the track. Without one, pass aria-label. */
  label?: ReactNode;
  /** Show the formatted value opposite the label. */
  showValue?: boolean;
  /** Step taken with Shift+Arrow and Page Up/Down. Defaults to 10 steps. */
  largeStep?: number;
  /** Dots on the track; give them a label to print it underneath (pressing a label jumps there). */
  marks?: SliderMark[];
  /** The value bubble over the thumb: while dragging or keyboard focused, always, or never. */
  tooltip?: "auto" | "always" | "never";
  /** Turns the number into the text shown in the readout, the bubble and to screen readers. */
  formatValue?: (value: number) => string;
  size?: Size;
  className?: string;
};

// Track thickness, thumb diameter and control height per size. The control is
// taller than the track so the whole band is a comfortable press target.
const sizes: Record<Size, { control: string; track: string; thumb: string }> = {
  sm: { control: "h-5", track: "h-1", thumb: "size-3" },
  md: { control: "h-6", track: "h-1", thumb: "size-4" },
  lg: { control: "h-7", track: "h-1.5", thumb: "size-5" },
};

const pct = (v: number, min: number, max: number) => ((v - min) / (max - min)) * 100;

export function Slider({
  value: valueProp,
  defaultValue,
  onValueChange,
  onValueCommitted,
  label,
  showValue = false,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  marks,
  tooltip = "auto",
  formatValue,
  format,
  locale,
  size = "md",
  disabled,
  className,
  "aria-label": ariaLabel,
  ...rest
}: SliderProps) {
  const [value, setValue] = useControllableState({
    value: valueProp,
    defaultValue: defaultValue ?? min,
    onChange: onValueChange,
  });
  // "glide" lets the thumb travel to a pressed point or a pressed label; drags and
  // keys switch it off so the thumb stays glued to the pointer and to each keystroke.
  const [motion, setMotion] = useState<"glide" | "instant">("glide");

  const fmt = useMemo(() => {
    if (formatValue) return formatValue;
    const nf = new Intl.NumberFormat(locale, format);
    return (v: number) => nf.format(v);
  }, [formatValue, format, locale]);

  const s = sizes[size];
  const p = pct(value, min, max);
  const list = (marks ?? []).map((m) => (typeof m === "number" ? { value: m, label: undefined } : m));
  const labeled = list.some((m) => m.label != null);

  return (
    <BaseSlider.Root
      value={value}
      min={min}
      max={max}
      step={step}
      largeStep={largeStep ?? step * 10}
      format={format}
      locale={locale}
      disabled={disabled}
      onValueChange={(next, details) => {
        setMotion(details.reason === "track-press" ? "glide" : "instant");
        setValue(next as number);
      }}
      onValueCommitted={(next, details) => {
        if (details.reason === "drag") setMotion("glide");
        onValueCommitted?.(next as number);
      }}
      data-size={size}
      data-motion={motion}
      data-tooltip={tooltip}
      className={cn("group/slider flex w-full flex-col gap-2 data-disabled:opacity-50", className)}
      {...rest}
    >
      {(label != null || showValue) && (
        <div className="flex items-baseline justify-between gap-3">
          {label != null && <BaseSlider.Label className="min-w-0 truncate text-[12.5px] font-medium tracking-[-0.005em] text-fg">{label}</BaseSlider.Label>}
          {showValue && (
            <span
              aria-hidden
              suppressHydrationWarning
              className={cn(
                "ml-auto shrink-0 text-[12.5px] text-fg-2 tabular transition-opacity duration-150",
                // While the bubble is up the number lives by the thumb, where the eyes are.
                tooltip === "auto" && "group-data-dragging/slider:opacity-0 group-has-focus-visible/slider:opacity-0",
              )}
            >
              {fmt(value)}
            </span>
          )}
        </div>
      )}

      <BaseSlider.Control
        className={cn(
          "group/control relative flex touch-none select-none items-center",
          "cursor-pointer group-data-disabled/slider:pointer-events-none",
          s.control,
        )}
      >
        <BaseSlider.Track className={cn("relative w-full rounded-full bg-fg/10 transition-colors duration-150", "group-hover/control:bg-fg/14", s.track)}>
          {/* The fill is ours rather than Base UI's so it can glide with the thumb. */}
          <div
            aria-hidden
            className={cn(
              "absolute inset-y-0 left-0 rounded-full bg-fg",
              "group-data-[motion=glide]/slider:transition-[width] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo",
            )}
            style={{ width: `${p}%` }}
          />

          {list.map((m) => {
            const at = pct(m.value, min, max);
            // A dot on the rounded cap reads as a smudge; the label still marks the end.
            if (at <= 0 || at >= 100) return null;
            return (
              <span
                key={m.value}
                aria-hidden
                data-passed={m.value <= value || undefined}
                className={cn(
                  "absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-150",
                  "bg-fg-3 data-passed:bg-frame/70",
                )}
                style={{ left: `${at}%` }}
              />
            );
          })}

          <BaseSlider.Thumb
            aria-label={ariaLabel}
            getAriaValueText={(_, v) => fmt(v)}
            className={cn(
              "group/thumb outline-none",
              // A 44px press target around a thumb that draws at 12–20px.
              "before:absolute before:-inset-3.5 before:content-['']",
              "group-data-[motion=glide]/slider:transition-[inset-inline-start] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo",
              s.thumb,
            )}
          >
            <span
              aria-hidden
              className={cn(
                "absolute inset-0 rounded-full bg-fg shadow-[var(--shadow)]",
                "outline-offset-2 group-has-focus-visible/thumb:outline-1 group-has-focus-visible/thumb:outline-solid group-has-focus-visible/thumb:outline-fg-3",
                "transition-[scale,box-shadow] duration-150 ease-out-expo",
                // Hover hints, grab commits: the thumb swells and a soft ring marks the hold.
                "group-hover/control:scale-110 group-data-dragging/slider:scale-125 group-data-dragging/slider:ring-4 group-data-dragging/slider:ring-fg/10",
                "motion-reduce:transition-none",
              )}
            />
            {tooltip !== "never" && (
              <span
                aria-hidden
                suppressHydrationWarning
                className={cn(
                  "pointer-events-none absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 origin-bottom whitespace-nowrap",
                  "rounded-md bg-fg px-1.5 py-0.5 text-[11.5px] font-medium leading-4 text-frame tabular shadow-[var(--shadow)]",
                  "transition-[opacity,scale,translate]",
                  tooltip === "always"
                    ? "opacity-100"
                    : cn(
                        // Hidden: small, low and transparent, leaving quickly. Shown: grows up out of the thumb.
                        "translate-y-1 scale-90 opacity-0 duration-100 ease-out",
                        "group-data-dragging/slider:translate-y-0 group-data-dragging/slider:scale-100 group-data-dragging/slider:opacity-100 group-data-dragging/slider:duration-150 group-data-dragging/slider:ease-out-expo",
                        "group-has-focus-visible/thumb:translate-y-0 group-has-focus-visible/thumb:scale-100 group-has-focus-visible/thumb:opacity-100 group-has-focus-visible/thumb:duration-150 group-has-focus-visible/thumb:ease-out-expo",
                        "motion-reduce:translate-y-0 motion-reduce:scale-100",
                      ),
                )}
              >
                {fmt(value)}
              </span>
            )}
          </BaseSlider.Thumb>
        </BaseSlider.Track>
      </BaseSlider.Control>

      {labeled && (
        // Pointer shortcut only: the thumb already covers every value from the keyboard.
        <div aria-hidden className="relative -mt-0.5 h-4">
          {list.map((m) =>
            m.label == null ? null : (
              <button
                key={m.value}
                type="button"
                tabIndex={-1}
                disabled={disabled}
                data-current={m.value === value || undefined}
                onClick={() => {
                  setMotion("glide");
                  setValue(m.value);
                  onValueCommitted?.(m.value);
                }}
                className={cn(
                  "absolute top-0 -translate-x-1/2 whitespace-nowrap rounded-sm px-1 text-[11px] leading-4 text-fg-3 tabular",
                  // The label draws 16px tall; the press target reaches 32px so it can be hit with a thumb.
                  "before:absolute before:-inset-x-1 before:-inset-y-2 before:content-['']",
                  "transition-[color,scale] duration-150 hover:not-data-current:text-fg-2 active:scale-95 data-current:text-fg",
                  "disabled:pointer-events-none",
                )}
                style={{ left: `${pct(m.value, min, max)}%` }}
              >
                {m.label}
              </button>
            ),
          )}
        </div>
      )}
    </BaseSlider.Root>
  );
}

05Props

PropTypeDefaultDescription
valuenumberControlled value. Pair with onValueChange.
defaultValuenumberminUncontrolled starting value.
onValueChange(value: number) => voidEvery change, including each frame of a drag.
onValueCommitted(value: number) => voidOnce per gesture: drag released, key pressed, track or mark label pressed. Save here.
labelReactNodeVisible label, wired to the thumb. Without it, pass aria-label.
showValuebooleanfalseFormatted value opposite the label.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Granularity of drags and arrow keys.
largeStepnumberstep × 10Taken with Shift+Arrow and Page Up/Down.
marksArray<number | { value: number; label?: ReactNode }>Dots on the track that brighten once passed. Labeled marks print underneath and jump there when pressed.
tooltip"auto" | "always" | "never""auto"The value bubble: while dragging or keyboard focused, always, or never.
formatValue(value: number) => stringText for the readout, the bubble and aria-valuetext. Falls back to Intl.NumberFormat with format and locale.
formatIntl.NumberFormatOptionsNumber formatting when formatValue isn't given.
localeIntl.LocalesArgumentLocale for the default formatter.
size"sm" | "md" | "lg""md"12, 16 or 20px thumb on a 4, 4 or 6px track.
disabledbooleanfalseDims to 50% and ignores pointer and keyboard.
namestringSubmits the value with a surrounding form.

06Notes

Behavior

  • Pressing the track glides the thumb and fill to the point; the moment the pointer moves it becomes a drag and the thumb locks to the finger with no easing.
  • While the bubble is up, the header readout fades out so the number lives in one place: by the thumb, where the eyes are.
  • Grabbing the thumb off-center doesn't make it jump; it keeps the offset for the whole drag.
  • Marks at the very ends skip the dot (it would smudge the rounded cap) but keep their label.

Motion

  • Thumb scales 1.1 on hover and 1.25 with a 4px ring on grab, 150ms expo ease-out, so the hold is felt before anything moves.
  • Track-press glide: 200ms expo ease-out on the thumb position and fill width. Drags and keys are instant; motion there would read as lag.
  • The bubble grows from the thumb: scale 0.9 → 1 and 4px up in 150ms, leaving in 100ms. Reduced motion keeps the fade and drops scale, travel and the glide.

Accessibility

  • Base UI thumb with a native range input: arrows step, Shift+arrows and Page Up/Down take the large step, Home and End jump to the ends.
  • The visible label is wired to the thumb; aria-valuetext uses formatValue so it reads “8 instances”, not “8”.
  • Mark labels are pointer shortcuts (tabIndex −1, hidden from screen readers) because the thumb already reaches every value; each one presses on a 32px-tall target.
  • The thumb draws at 12–20px but presses land on a 44px target.