Skip to content

Slider

Pick a value, or a range, by dragging or with the keyboard. Built on Radix Slider. For exact numbers people type, use Number Input.

60%

Installation

Add Slider to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add slider
Adds
Slider.tsxField.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-label@radix-ui/react-slider@radix-ui/react-slotif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install @radix-ui/react-slider @radix-ui/react-slot @radix-ui/react-label

Add the Field component

Slider imports ./Field, so add this file next to it

/components/ui/Field.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";

/*
 * Field wires a label, description and message to one form control:
 *   Field.Label        → <label for={controlId}>
 *   Field.Description  → referenced by aria-describedby
 *   Field.Message      → referenced by aria-describedby (error text when invalid)
 *   invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
 * Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
 * useFieldControlProps. It is plain React state: it works with native forms and form libraries.
 */

type FieldContextValue = {
    controlId: string;
    labelId: string;
    descriptionId: string;
    messageId: string;
    invalid: boolean;
    required: boolean;
    disabled: boolean;
    hasDescription: boolean;
    hasMessage: boolean;
    setHasDescription: (value: boolean) => void;
    setHasMessage: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
    return React.useContext(FieldContext);
}

/**
 * Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
 * this lets each Field.Root reflect that state (label styling, `disabled` on the control).
 */
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);

export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Id for the control; generated when omitted. */
    id?: string;
    invalid?: boolean;
    required?: boolean;
    disabled?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
    const fieldset = React.useContext(FieldsetContext);
    const disabled = disabledProp || Boolean(fieldset?.disabled);
    const generatedId = React.useId();
    const controlId = id ?? `field-${generatedId}`;
    const [hasDescription, setHasDescription] = React.useState(false);
    const [hasMessage, setHasMessage] = React.useState(false);

    const value = React.useMemo<FieldContextValue>(
        () => ({
            controlId,
            labelId: `${controlId}-label`,
            descriptionId: `${controlId}-description`,
            messageId: `${controlId}-message`,
            invalid,
            required,
            disabled,
            hasDescription,
            hasMessage,
            setHasDescription,
            setHasMessage,
        }),
        [controlId, invalid, required, disabled, hasDescription, hasMessage]
    );

    return (
        <FieldContext.Provider value={value}>
            <div
                ref={ref}
                className={cn("flex flex-col gap-1.5", className)}
                data-invalid={invalid || undefined}
                data-disabled={disabled || undefined}
                {...props}
            >
                {children}
            </div>
        </FieldContext.Provider>
    );
});
Root.displayName = "Field.Root";

type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;

const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
    const field = useField();

    return (
        <Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
            {children}
            {field?.required && <Label.Asterisk />}
        </Label.Root>
    );
});
FieldLabel.displayName = "Field.Label";

const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
    const field = useField();
    const setHasDescription = field?.setHasDescription;

    React.useEffect(() => {
        if (!setHasDescription) return;
        setHasDescription(true);
        return () => setHasDescription(false);
    }, [setHasDescription]);

    return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";

export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
    /** Only render while the field is invalid (default true). */
    showWhenValid?: boolean;
}

const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
    const field = useField();
    const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
    const setHasMessage = field?.setHasMessage;

    React.useEffect(() => {
        if (!setHasMessage || !visible) return;
        setHasMessage(true);
        return () => setHasMessage(false);
    }, [setHasMessage, visible]);

    if (!visible) return null;

    return (
        <p
            ref={ref}
            id={field?.messageId}
            aria-live="polite"
            className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
            {...props}
        >
            {children}
        </p>
    );
});
Message.displayName = "Field.Message";

type ControlAttributes = {
    id?: string;
    "aria-describedby"?: string;
    "aria-invalid"?: React.AriaAttributes["aria-invalid"];
    "aria-required"?: React.AriaAttributes["aria-required"];
    required?: boolean;
    disabled?: boolean;
};

/**
 * Merges Field context into a control's props. Explicit props win; describedby ids are combined.
 */
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
    const field = useField();
    const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");

    const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
        .filter(Boolean)
        .join(" ");

    return {
        ...props,
        id: props.id ?? field?.controlId,
        "aria-describedby": describedBy || undefined,
        "aria-invalid": invalid || undefined,
        required: props.required ?? (field?.required || undefined),
        disabled: props.disabled ?? (field?.disabled || undefined),
    };
}

/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
    const controlProps = useFieldControlProps(props);
    return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";

export { Root, FieldLabel as Label, Description, Message, Control };

Add the Label component

Slider imports ./Label, so add this file next to it

/components/ui/Label.tsx
"use client";

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

export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
    /** Dims the label. The control itself must also be disabled. */
    disabled?: boolean;
}

const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
    return (
        <LabelPrimitive.Root
            ref={ref}
            data-disabled={disabled || undefined}
            className={cn(
                "group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
                "data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
                className
            )}
            {...props}
        />
    );
});
Root.displayName = "Label.Root";

// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
    <span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
        {children || "*"}
    </span>
));
Asterisk.displayName = "Label.Asterisk";

const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
    <span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";

export { Root, Asterisk, Sub };

Create or update your Slider component

Copy this code to create your Slider component

/components/ui/Slider.tsx
"use client";

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
import { useField, useFieldControlProps } from "./Field";

/*
 * Built on Radix Slider: each thumb is role="slider" with aria-valuenow/min/max and keyboard support
 * (arrows by `step`, PageUp/PageDown by 10 steps, Home/End to min/max). Pass two values for a range.
 * Naming: inside Field.Root the thumb is labelled by Field.Label; range thumbs need `thumbLabels`.
 * Use `getValueLabel` when the number alone is not meaningful ("$40", "40%").
 */

export interface SliderRootProps extends Omit<React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>, "asChild"> {
    /** Text read for each thumb's value (aria-valuetext). */
    getValueLabel?: (value: number, index: number) => string;
    /** Accessible name per thumb, e.g. ["Minimum price", "Maximum price"]. */
    thumbLabels?: string[];
    invalid?: boolean;
    "aria-label"?: string;
    "aria-labelledby"?: string;
    "aria-describedby"?: string;
}

const Root = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderRootProps>(
    (
        {
            className,
            value: valueProp,
            defaultValue,
            onValueChange,
            min = 0,
            max = 100,
            getValueLabel,
            thumbLabels,
            invalid,
            disabled,
            id,
            "aria-label": ariaLabel,
            "aria-labelledby": ariaLabelledBy,
            "aria-describedby": ariaDescribedBy,
            ...props
        },
        ref
    ) => {
        const field = useField();
        const controlProps = useFieldControlProps({ id, "aria-describedby": ariaDescribedBy, disabled }, { invalid });
        // Mirror the values so the right number of thumbs (and their value text) is rendered.
        const [uncontrolled, setUncontrolled] = React.useState<number[]>(defaultValue ?? [min]);
        const values = valueProp ?? uncontrolled;
        const isInvalid = Boolean(controlProps["aria-invalid"]);

        return (
            <SliderPrimitive.Root
                ref={ref}
                value={valueProp}
                defaultValue={defaultValue ?? (valueProp ? undefined : [min])}
                onValueChange={(next) => {
                    if (valueProp === undefined) setUncontrolled(next);
                    onValueChange?.(next);
                }}
                min={min}
                max={max}
                disabled={Boolean(controlProps.disabled)}
                data-invalid={isInvalid || undefined}
                className={cn(
                    "relative flex w-full touch-none select-none items-center py-2",
                    "data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[orientation=vertical]:px-2 data-[orientation=vertical]:py-0",
                    "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
                    className
                )}
                {...props}
            >
                <SliderPrimitive.Track className="relative grow overflow-hidden rounded-full bg-foreground/[0.08] data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5">
                    <SliderPrimitive.Range
                        className={cn("absolute rounded-full data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full", isInvalid ? "bg-destructive-strong" : "bg-primary-strong")}
                    />
                </SliderPrimitive.Track>
                {values.map((value, index) => {
                    const thumbLabel = thumbLabels?.[index];
                    // A thumb without its own label takes the slider's: aria-label, aria-labelledby or Field.Label.
                    const labelledBy = thumbLabel ? undefined : (ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId));
                    return (
                        <SliderPrimitive.Thumb
                            key={index}
                            id={index === 0 ? controlProps.id : undefined}
                            aria-label={thumbLabel ?? (labelledBy ? undefined : ariaLabel)}
                            aria-labelledby={labelledBy}
                            aria-describedby={controlProps["aria-describedby"]}
                            aria-valuetext={getValueLabel?.(value, index)}
                            aria-invalid={isInvalid || undefined}
                            className={cn(
                                "block size-5 rounded-full border-2 bg-background shadow-regular transition-[box-shadow,transform] duration-fast ease-standard focus-ring",
                                "hover:scale-110 motion-reduce:hover:scale-100 data-[disabled]:pointer-events-none",
                                isInvalid ? "border-destructive-strong" : "border-primary-strong"
                            )}
                        />
                    );
                })}
            </SliderPrimitive.Root>
        );
    }
);
Root.displayName = "Slider.Root";

export { Root };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Range

Pass two values for a range. Name each thumb with thumbLabels and use minStepsBetweenThumbs to keep them apart.

$40 – $160

Stepped and Disabled

getValueLabel turns positions into words (sizes here), so screen readers say “M”, not “2”.

Vertical

orientation="vertical"; ArrowUp increases the value.

Accessibility

  • Each thumb is role="slider" with aria-valuenow, aria-valuemin and aria-valuemax. Inside Field.Root a single thumb is labelled by Field.Label; give range thumbs their own names.
  • Arrows change by step, PageUp/PageDown by 10 steps, Home/End go to the ends. getValueLabel sets aria-valuetext ("60%", "$40").
  • Thumbs show the shared focus ring; the fill and thumb border use contrast-safe tokens.
  • With name, the value is submitted with native forms (name[] for ranges).

API Reference

Import with import * as Slider from "@/components/ui/Slider". Track, range and thumbs are rendered for you.

Slider.Root

See Radix UI docs for base props.

PropTypeDefaultDescription
value / defaultValue"number[]""[min]"

One value, or two for a range.

onValueChange"(value: number[]) => void""undefined"

While dragging or stepping.

onValueCommit"(value: number[]) => void""undefined"

When the interaction ends.

min / max / step"number""0 / 100 / 1"

Range and increment.

minStepsBetweenThumbs"number""0"

Gap between range thumbs.

orientation
"horizontal""vertical"
"horizontal"

Direction.

getValueLabel"(value, index) => string""undefined"

aria-valuetext for each thumb.

thumbLabels"string[]""undefined"

Accessible name per thumb (ranges).

disabled / invalid"boolean""from Field"

State; also read from Field.Root.

name"string""undefined"

Native form submission.

className"string""undefined"

Additional classes, merged last.