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
minimummaximumPass 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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | Label on the left and the accessible name of the thumbs. | |
| value | number | number[] | Controlled value. An array gives one thumb per entry. | |
| defaultValue | number | number[] | min | Starting value when uncontrolled. |
| onValueChange | (value: number | number[]) => void | Fires on every move, same shape as value. | |
| onValueCommit | (value: number | number[]) => void | Fires when the pointer or key is released. | |
| min, max, step | number | 0, 100, 1 | The scale. |
| format | (value: number) => string | String | Formats 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