Facetcomponents

Slider

Three instances whose geometry is the primitive's, and the one track with 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.

The one component in the registry with no passthrough bag: three separate instances render here, so 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 that omission is the component’s whole design decision.

A flex-* class lowers to a UIListLayout, which positions every child it has. The range’s fill and the thumb’s travel are Position/Size written by Lattice as the value moves, so a layout 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. A save wired to onValueChange is 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.