Slider
A slider component based on the native range input
import { Slider } from "@/components/slider";
export default function SliderPreview() {
return (
<Slider min={0} max={100} defaultValue={50}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
);
}Dependencies
Source Code
import { createContext, use, useCallback, useMemo, useRef, useState } from "react";
import { Slot } from "@/components/slot";
import { composeRefs } from "@/lib/compose-refs";
import { cn, cva } from "@/lib/utils/classnames";
interface SliderContextValue {
value: number;
setValue: (value: number) => void;
min: number;
max: number;
step: number;
orientation: "horizontal" | "vertical";
disabled: boolean;
onValueChange?: (value: number) => void;
progressFactor: number;
}
const SliderContext = createContext<SliderContextValue | null>(null);
const useSliderContext = () => {
const context = use(SliderContext);
if (!context) {
throw new Error("Slider components must be used within a Slider");
}
return context;
};
const sliderStyle = cva({
base: [
"[--track-thickness:--spacing(2)]",
"[--thumb-size:--spacing(4)]",
"relative inline-flex outline-none focus:outline-none",
],
variants: {
orientation: {
horizontal: "h-(--track-thickness) w-56 items-center",
vertical: "h-56 w-(--track-thickness) justify-center",
},
disabled: {
true: "pointer-events-none opacity-60",
false: "",
},
},
defaultVariants: {
orientation: "horizontal",
disabled: false,
},
});
interface SliderProps
extends Omit<React.ComponentPropsWithRef<"div">, "onChange" | "defaultValue"> {
min?: number;
max?: number;
step?: number;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
asChild?: boolean;
}
const Slider = ({
min = 0,
max = 100,
step = 1,
value: valueProp,
defaultValue = min,
onValueChange,
orientation = "horizontal",
disabled = false,
className,
asChild,
children,
ref,
...props
}: SliderProps) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const value = valueProp ?? internalValue;
const setValue = useCallback(
(newValue: number) => {
if (valueProp === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
},
[valueProp, onValueChange],
);
const progressFactor = useMemo(
() => (max === min ? 0 : (value - min) / (max - min)),
[value, min, max],
);
const contextValue = useMemo(
() => ({
value,
setValue,
min,
max,
step,
orientation,
disabled,
onValueChange,
progressFactor,
}),
[value, setValue, min, max, step, orientation, disabled, onValueChange, progressFactor],
);
const Comp = asChild ? Slot : "div";
return (
<SliderContext value={contextValue}>
<Comp
ref={ref}
className={cn(sliderStyle({ orientation, disabled }), className)}
style={{
"--progress-track-factor": `${progressFactor}`,
}}
tabIndex={-1}
{...props}
>
{children}
</Comp>
</SliderContext>
);
};
const sliderTrackStyle = cva({
base: ["pointer-events-none absolute rounded-full bg-foreground-secondary/40"],
variants: {
orientation: {
// Inset the track ends by a quarter of the thumb size so the thumb visually
// extends slightly past the track at min/max, hiding the sliver of track
// that would otherwise peek out around the thumb's circular edge.
horizontal:
"right-[calc(var(--thumb-size)/4)] left-[calc(var(--thumb-size)/4)] h-(--track-thickness)",
vertical:
"top-[calc(var(--thumb-size)/4)] bottom-[calc(var(--thumb-size)/4)] w-(--track-thickness)",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
interface SliderTrackProps extends React.ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
const SliderTrack = ({ className, asChild, ref, ...props }: SliderTrackProps) => {
const { orientation } = useSliderContext();
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
className={cn(sliderTrackStyle({ orientation }), className)}
tabIndex={-1}
{...props}
/>
);
};
const sliderRangeStyle = cva({
base: ["pointer-events-none absolute z-5 rounded-full bg-accent/90"],
variants: {
orientation: {
horizontal: "left-0 h-(--track-thickness)",
vertical: "bottom-0 w-(--track-thickness)",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
interface SliderRangeProps extends React.ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
const SliderRange = ({ className, asChild, ref, style, ...props }: SliderRangeProps) => {
const { orientation } = useSliderContext();
const Comp = asChild ? Slot : "div";
const rangeStyle = {
[orientation === "horizontal" ? "width" : "height"]:
`calc(var(--progress-track-factor) * 100%)`,
...style,
};
return (
<Comp
ref={ref}
className={cn(sliderRangeStyle({ orientation }), className)}
style={rangeStyle}
tabIndex={-1}
{...props}
/>
);
};
const sliderThumbStyle = cva({
base: [
"absolute z-10 cursor-pointer appearance-none rounded-full outline-none",
// Reset default styles
"[&::-webkit-slider-thumb]:appearance-none",
// Thumb styles
"[&::-webkit-slider-thumb]:transition",
"[&::-webkit-slider-thumb]:h-(--thumb-size) [&::-webkit-slider-thumb]:w-(--thumb-size)",
"[&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-xs",
"[&::-webkit-slider-thumb]:z-10 [&::-webkit-slider-thumb]:rounded-full",
"[&::-moz-range-thumb]:transition",
"[&::-moz-range-thumb]:h-(--thumb-size) [&::-moz-range-thumb]:w-(--thumb-size)",
"[&::-moz-range-thumb]:border [&::-moz-range-thumb]:border-border [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-xs",
"[&::-moz-range-thumb]:rounded-full",
// Hover states
"[&::-webkit-slider-thumb:hover]:scale-130",
"[&::-moz-range-thumb:hover]:scale-130",
// Active states
"[&:active::-webkit-slider-thumb]:scale-130",
"[&:active::-moz-range-thumb]:scale-130",
// Focus states - apply to pseudo-elements only
"[&:focus-visible::-webkit-slider-thumb]:ring-(length:--ring-width)",
"[&:focus-visible::-webkit-slider-thumb]:ring-ring",
"[&:focus-visible::-moz-range-thumb]:ring-(length:--ring-width)",
"[&:focus-visible::-moz-range-thumb]:ring-ring",
],
variants: {
orientation: {
horizontal: [
"h-(--track-thickness) w-full",
"[&::-webkit-slider-thumb]:-mt-[calc(var(--thumb-size)-var(--track-thickness))/2]",
"[&::-moz-range-thumb]:-mt-[calc(var(--thumb-size)-var(--track-thickness))/2]",
"[&::-webkit-slider-runnable-track]:h-(--track-thickness)",
"[&::-moz-range-track]:h-(--track-thickness)",
],
vertical: [
"h-full w-(--track-thickness) [direction:rtl] [writing-mode:vertical-lr]",
"[&::-webkit-slider-thumb]:-ml-[calc(var(--thumb-size)-var(--track-thickness))/2]",
"[&::-moz-range-thumb]:-ml-[calc(var(--thumb-size)-var(--track-thickness))/2]",
"[&::-webkit-slider-runnable-track]:w-(--track-thickness)",
"[&::-moz-range-track]:w-(--track-thickness)",
],
},
},
defaultVariants: {
orientation: "horizontal",
},
});
type SliderThumbProps = Omit<React.ComponentPropsWithRef<"input">, "value" | "type">;
const SliderThumb = ({ className, onChange, ref, ...props }: SliderThumbProps) => {
const { value, setValue, min, max, step, orientation, disabled } = useSliderContext();
const internalRef = useRef<HTMLInputElement>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setValue(newValue);
// Update CSS variable for range visualization
const sliderElement = e.target.closest('[style*="--progress-track-factor"]') as HTMLElement;
if (sliderElement) {
sliderElement.style.setProperty(
"--progress-track-factor",
`${max === min ? 0 : (newValue - min) / (max - min)}`,
);
}
onChange?.(e);
},
[setValue, min, max, onChange],
);
return (
<input
ref={composeRefs(ref, internalRef)}
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={handleChange}
className={cn(sliderThumbStyle({ orientation }), className)}
{...props}
/>
);
};
const CompoundSlider = Object.assign(Slider, {
Track: SliderTrack,
Range: SliderRange,
Thumb: SliderThumb,
});
export { CompoundSlider as Slider };API Reference
A composable slider component built on the native range input.
Slider
The root container component.
| Prop | Default | Type | Description |
|---|---|---|---|
min | 0 | number | Minimum value of the slider. |
max | 100 | number | Maximum value of the slider. |
step | 1 | number | Step increment value. |
value | - | number | Controlled value of the slider. |
defaultValue | - | number | Default value for uncontrolled usage. |
onValueChange | - | (value: number) => void | Callback function when value changes. |
orientation | "horizontal" | "horizontal" | "vertical" | Orientation of the slider. |
disabled | false | boolean | Whether the slider is disabled. |
asChild | false | boolean | Render as child element using Slot. |
Slider.Track
The track background element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as child element using Slot. |
Slider.Range
The filled range portion of the track.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as child element using Slot. |
Slider.Thumb
The draggable thumb element (renders as input[type="range"]). Extends all standard input props except value and type.
The component uses CSS variables for customization:
--track-thickness- Thickness of the slider track--thumb-size- Size of the slider thumb
Examples
Simple
Basic usage with composable structure. The slider requires Slider.Track, Slider.Range, and Slider.Thumb children.
<Slider min={0} max={100} defaultValue={50}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
import { Slider } from "@/components/slider";
export default function SliderPreview() {
return (
<Slider min={0} max={100} defaultValue={50}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
);
}Steps
Slider with defined step intervals.
import { Slider } from "@/components/slider";
export default function SliderPreview() {
return (
<Slider min={0} max={100} defaultValue={50} step={10}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
);
}Vertical
Vertical orientation of the slider.
import { Slider } from "@/components/slider";
export default function SliderPreview() {
return (
<Slider min={0} max={100} defaultValue={50} orientation="vertical">
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
);
}Disabled
Disabled state of the slider.
import { Slider } from "@/components/slider";
export default function SliderPreview() {
return (
<Slider min={0} max={100} defaultValue={50} disabled>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider>
);
}Best Practices
-
Usage Guidelines:
- Always provide clear min and max values
- Consider step intervals for precise control
- Use appropriate slider length for the value range
- Set a meaningful default value
-
Accessibility:
- Provide clear visual feedback
- Include aria-labels for screen readers
- Ensure keyboard navigation works (arrow keys)
-
Interaction:
- Provide immediate visual feedback
- Consider adding value tooltips for precision
- Handle both mouse and touch interactions
Previous
Skeleton
Next
Spinner