Facetcomponents

Switch

A track and a thumb whose travel is the primitive's job.

Terminal window
npx facet-rbxts add switch

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

import { Switch } from "../shared/ui/switch";
<Switch defaultChecked onCheckedChange={(checked) => setMuted(!checked)} />
Off, on, and disabled. The thumb's position is Lattice's; the track's colour is this file's.

Renders a TextButton. Unknown props forward onto it and are type-checked against it, so a prop TextButton does not accept is a compile error. The primitive owns AnchorPoint and Position on the thumb, which it animates between the track's edges, so values you pass for those are ignored.

Props

PropTypeDescription
checkedbooleanControlled value. Pass it with onCheckedChange.
defaultCheckedbooleanUncontrolled starting value. Defaults to false.
onCheckedChange(checked: boolean) => voidFires on every change, controlled or not.
disabledbooleanBlocks the press and adds opacity-50 to the recipe's className slot.
classNameClassNameThreaded into the 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.

No indeterminate here, unlike Checkbox — a switch is on or off, and Lattice types it boolean.

Two classes, and one of them is the whole component

export const switchVariants = {
root: fv("h-5 w-9 rounded-full transition duration-150"),
thumb: fv("size-4 rounded-full bg-background"),
};

The root carries no background colour at all. It is applied from the mirrored state instead:

const className = switchVariants.root({
className: cn(checked ? "bg-primary" : "bg-input", disabled && "opacity-50", props.className),
});

That is the mirrored-state shape checkbox establishes, because Lattice keeps its context private and the track’s colour changes with the value.

The thumb has no position

Layout is an instance, not a property seen from the other side: a primitive that owns a property is as load-bearing as a UIListLayout that owns one.

Sizing it

h-5 w-9 on the track and size-4 on the thumb are the only numbers in the component, and they are paired. A 16px thumb inside a 20px track leaves 2px of inset each side. Changing one means changing the other — there is no variant, because the file is short enough to edit.

A call-site className cannot do it either. h-5 w-9 is in the recipe, which resolves after the props a call-site class arrives as. See overriding from the call site.