npx facet-rbxts add sliderCopies 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)} />import React from "@rbxts/react";import { Label } from "../ui/label";import { Slider } from "../ui/slider";import { MODE } from "../facet-mode";
/** * The thumb overhangs the track because it is `size-4` on an `h-2` bar and * rides the track directly rather than being laid out inside it — the geometry * that only Studio can confirm, and the reason the track carries no `flex-*`. */export function Sliders() { return ( <frame className="flex-col gap-4 w-full h-fit"> <frame className="flex-col gap-2 w-full h-fit"> <Label Text="Master volume" /> <Slider defaultValue={70} /> </frame> <frame className="flex-col gap-2 w-full h-fit"> <Label Text="Sensitivity — stepped by 10" /> <Slider defaultValue={40} step={10} /> </frame> <frame className="flex-col gap-2 w-full h-fit"> <Label Text="Disabled" /> <Slider defaultValue={25} disabled /> </frame> </frame> );}import { fv } from "@facet-ui/react-variants";import { React } from "@lattice-ui/react-runtime";import { type SliderOrientation, Slider as SliderPrimitive } from "@lattice-ui/react-slider";import { type ClassName, cn } from "~/lib/utils";
/** * The track deliberately carries no `flex-*`: a `UIListLayout` lays out every * child and would override the geometry Lattice owns — the range's fill and the * thumb's travel are both `Position`/`Size` on instances inside it. The thumb * rides the track directly (its anchor is centred by the primitive), so it * overhangs a thinner track the way a slider knob should. * * One recipe object rather than three exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const sliderVariants = { track: fv("h-1.5 w-full rounded-full bg-muted"), range: fv("rounded-full bg-primary"), thumb: fv("shrink-0 size-4 rounded-full border border-primary bg-background shadow"),};
export type SliderProps = { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; /** Fires once per gesture, when the drag or keypress lets go. */ onValueCommit?: (value: number) => void; min?: number; max?: number; step?: number; orientation?: SliderOrientation; disabled?: boolean; className?: ClassName;};
export function Slider(props: SliderProps) { const disabled = props.disabled === true;
return ( <SliderPrimitive.Root defaultValue={props.defaultValue} disabled={disabled} max={props.max} min={props.min} onValueChange={props.onValueChange} onValueCommit={props.onValueCommit} orientation={props.orientation} step={props.step} value={props.value} > <SliderPrimitive.Track className={sliderVariants.track({ className: cn(disabled && "opacity-50", props.className) })} > <SliderPrimitive.Range className={cn(sliderVariants.range())} /> <SliderPrimitive.Thumb className={cn(sliderVariants.thumb(), disabled && "opacity-50")} /> </SliderPrimitive.Track> </SliderPrimitive.Root> );}Props
| Prop | Type | Description |
|---|---|---|
| value | number | Controlled value. Pass it with onValueChange. |
| defaultValue | number | Uncontrolled starting value. |
| onValueChange | (value: number) => void | Fires continuously as the thumb moves. |
| onValueCommit | (value: number) => void | Fires once per gesture, when the drag or the keypress lets go. This is the one to save from. |
| min | number | Lower bound. |
| max | number | Upper bound. |
| step | number | Quantises the value. The thumb still moves smoothly; the value does not. |
| orientation | "horizontal" | "vertical" | Which axis the thumb travels along. |
| disabled | boolean | Blocks the drag, and fades the track and the thumb with opacity-50. |
| className | ClassName | Threaded 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.
size-4 on an h-2 bar means the thumb is twice the track’s height and hangs over both edges —
which is what a slider knob looks like, and only possible because nothing is laying it out. The
primitive centres its anchor; the overhang follows.
What Studio still has to confirm is that the overhang lands symmetrically and the range fill stops exactly under the thumb. Loom renders it, but the preview is not Studio.
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.