Facetcomponents

Slider

Three instances whose geometry is entirely the primitive's — and the one component whose track deliberately has no layout.

Terminal window
npx facet-rbxts add slider

Copies ui/slider.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants, @lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-slider@^0.8.0.

import { Slider } from "../shared/ui/slider";
<Slider defaultValue={70} onValueCommit={(value) => saveVolume(value)} />
A plain slider, one stepped by 10, and a disabled one. The thumb overhangs the track because it is size-4 on an h-2 bar.

Props

PropTypeDescription
valuenumberControlled value. Pass it with onValueChange.
defaultValuenumberUncontrolled starting value.
onValueChange(value: number) => voidFires continuously as the thumb moves.
onValueCommit(value: number) => voidFires once per gesture, when the drag or the keypress lets go. This is the one to save from.
minnumberLower bound.
maxnumberUpper bound.
stepnumberQuantises the value. The thumb still moves smoothly; the value does not.
orientation"horizontal" | "vertical"Which axis the thumb travels along.
disabledbooleanBlocks the drag, and fades the track and the thumb with opacity-50.
classNameClassNameThreaded into the track recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site.

This is the one component in the registry with no passthrough bag. There is no PassthroughProps<Frame> on SliderProps and no getPassthroughProps in the body: three separate instances render here and there is no single one an unknown prop should land on.

The track has no flex-*, and that is the point

export const sliderVariants = {
track: fv("h-2 w-full rounded-full bg-secondary"),
range: fv("rounded-full bg-primary"),
thumb: fv("size-4 rounded-full border border-primary bg-background"),
};

Every other container in the registry opens with flex-row or flex-col. This one does not, and removing that class is the component’s whole design decision.

A flex-* class lowers to a UIListLayout, and a UIListLayout positions every child it has. The range’s fill and the thumb’s travel are both Position/Size on instances inside the track, written by Lattice as the value moves. Add a layout and the list overwrites them on the next frame.

onValueChange versus onValueCommit

onValueChange fires on every frame of a drag. onValueCommit fires once, when the gesture ends.

For a volume slider you want both: the first to hear the change live, the second to write it to a DataStore. Wiring a save to onValueChange means one request per frame of a drag.

Disabled fades in two places

<SliderPrimitive.Track className={sliderVariants.track({ className: cn(disabled && "opacity-50", props.className) })}>
<SliderPrimitive.Range />
<SliderPrimitive.Thumb className={cn(sliderVariants.thumb(), disabled && "opacity-50")} />
</SliderPrimitive.Track>

The track and the thumb each state the fade, because opacity-* does not cross a component boundary — the thumb is a primitive child, not JSX the compiler can see under the track. The registry’s rule is the same one it always is: nothing inherits, so every instance states its own appearance.