Skip to content
goodtabs UI
BrowseBrowse components

Slider field

A slider with a label, the current value in mono and min and max marks.

npx shadcn@latest add https://ui.goodtabs.co/r/slider-field.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/slider-field

Volume

Volume from 0 to 100, controlled from state.

With a format

Monthly budget

A budget in dollars; format shapes the value and the marks.

Range, two thumbs

Price
minimummaximum

Pass a number[] and the value and callbacks become arrays.

Usage

import { SliderField } from "@/components/ui/slider-field"

<SliderField label="Volume" defaultValue={40} />
<SliderField label="Price" defaultValue={[200, 800]} max={1000} step={50} format={(v) => `$${v}`} />

Props

PropTypeDefaultDescription
labelReactNodeLabel on the left and the accessible name of the thumbs.
valuenumber | number[]Controlled value. An array gives one thumb per entry.
defaultValuenumber | number[]minStarting value when uncontrolled.
onValueChange(value: number | number[]) => voidFires on every move, same shape as value.
onValueCommit(value: number | number[]) => voidFires when the pointer or key is released.
min, max, stepnumber0, 100, 1The scale.
format(value: number) => stringStringFormats the display and the marks.

Source

slider-field/slider-field.tsx
"use client";

import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";

type SliderValue = number | number[];

type SliderFieldProps<T extends SliderValue> = Omit<
  React.ComponentProps<typeof SliderPrimitive.Root>,
  "value" | "defaultValue" | "onValueChange" | "onValueCommit"
> & {
  /** Label on the left. Also the accessible name of every thumb. */
  label: React.ReactNode;
  /** Controlled value. A number for one thumb, a number[] for a range. */
  value?: T;
  /** Starting value for the uncontrolled form. Same shape as value. */
  defaultValue?: T;
  /** Fires with the next value on every move, in the same shape as value. */
  onValueChange?: (value: T) => void;
  /** Fires once the pointer or key is released. */
  onValueCommit?: (value: T) => void;
  /** Formats a number for the display and the marks, for example a price. */
  format?: (value: number) => string;
};

const thumbNames = ["minimum", "maximum"];

/**
 * A slider with its label on the left, the current value in mono on the right,
 * and the min and max marks under the track. Pass a number[] for two thumbs.
 */
export function SliderField<T extends SliderValue = number>({
  label,
  value,
  defaultValue,
  onValueChange,
  onValueCommit,
  format = String,
  min = 0,
  max = 100,
  step = 1,
  className,
  ...props
}: SliderFieldProps<T>) {
  const range = Array.isArray(value ?? defaultValue);
  const toArray = (v: SliderValue | undefined): number[] => (v === undefined ? [min] : Array.isArray(v) ? v : [v]);
  const fromArray = (next: number[]) => (range ? next : next[0]) as T;
  const [inner, setInner] = React.useState<number[]>(() => toArray(defaultValue));
  const current = value !== undefined ? toArray(value) : inner;
  const id = React.useId();
  const labelId = `${id}-label`;

  return (
    <div data-slot="slider-field" className={cn("flex w-full flex-col gap-2", className)}>
      <div className="flex items-baseline justify-between gap-3">
        <span id={labelId} className="text-sm leading-none font-medium">
          {label}
        </span>
        <span aria-hidden="true" className="font-mono text-xs text-muted-foreground tabular-nums">
          {current.map((v) => format(v)).join(" to ")}
        </span>
      </div>
      <SliderPrimitive.Root
        data-slot="slider"
        value={current}
        onValueChange={(next) => {
          if (value === undefined) setInner(next);
          onValueChange?.(fromArray(next));
        }}
        onValueCommit={(next) => onValueCommit?.(fromArray(next))}
        min={min}
        max={max}
        step={step}
        className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50"
        {...props}
      >
        <SliderPrimitive.Track
          data-slot="slider-track"
          className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted"
        >
          <SliderPrimitive.Range data-slot="slider-range" className="absolute h-full bg-primary" />
        </SliderPrimitive.Track>
        {current.map((v, i) => (
          <React.Fragment key={i}>
            {range ? (
              <span id={`${id}-thumb-${i}`} className="sr-only">
                {thumbNames[i] ?? `value ${i + 1}`}
              </span>
            ) : null}
            <SliderPrimitive.Thumb
              data-slot="slider-thumb"
              aria-labelledby={range ? `${labelId} ${id}-thumb-${i}` : labelId}
              aria-valuetext={format(v)}
              className="block size-3 shrink-0 rounded-full border border-primary bg-background ring-ring/50 transition-[box-shadow] duration-100 ease-out outline-none select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 active:ring-3 disabled:pointer-events-none disabled:opacity-50"
            />
          </React.Fragment>
        ))}
      </SliderPrimitive.Root>
      <div aria-hidden="true" className="flex justify-between font-mono text-xs text-muted-foreground tabular-nums">
        <span>{format(min)}</span>
        <span>{format(max)}</span>
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/slider-field/prompt.md, or the Copy AI prompt button above.

When to use

  • One number on a known scale where seeing the position matters: volume, opacity, budget
  • A range with a lower and upper bound, such as a price filter
  • Settings panels where the value should update as it moves

When not to use

  • For exact values people type, use an Input with type number
  • For scales with many steps or no natural end, use an input or a select
  • For on and off, use a switch

Agent notes

  • The shape of value decides the thumbs: a number is one thumb, a number[] is one thumb per entry, and onValueChange returns the same shape
  • onValueChange and format are functions, so render it from a client component; defaultValue alone works anywhere
  • It uses the radix Slider directly rather than shadcn's slider, so every thumb can carry aria-labelledby and aria-valuetext
  • The value display is aria-hidden; screen readers get the formatted value from aria-valuetext on the thumb