npx facet-rbxts add switchCopies 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)} />import React from "@rbxts/react";import { Label } from "../ui/label";import { Switch } from "../ui/switch";import { MODE } from "../facet-mode";
/** * The track's colour is the only thing this file's recipe decides between the * two states — the thumb's travel is Lattice's, which is why the on and off * rows differ in fill but never in geometry. */export function Switches() { return ( <frame className="flex-col gap-3 w-full h-fit"> <frame className="flex-row items-center gap-3 w-full h-fit"> <Switch /> <Label Text="Off" /> </frame> <frame className="flex-row items-center gap-3 w-full h-fit"> <Switch defaultChecked /> <Label Text="On" /> </frame> <frame className="flex-row items-center gap-3 w-full h-fit"> <Switch defaultChecked disabled /> <Label Text="Disabled" /> </frame> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps, useControllableState,} from "@lattice-ui/react-runtime";import { Switch as SwitchPrimitive } from "@lattice-ui/react-switch";import { type ClassName, cn } from "~/lib/utils";
/** * The checked state is mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and the * track's colour changes with it. The primitive is then driven controlled, so * there is exactly one copy of the state and it lives in this file. * * The thumb's travel is not styled here at all: Lattice's `Switch.Thumb` owns * `AnchorPoint` and `Position` and animates them between the track's edges for * any thumb size. This file only says what the thumb looks like. */export const switchVariants = { // shadcn's track is `h-[1.15rem] w-8` — 18.4 by 32. `h-4.5` is 18, the nearest // step Vela has, and the half-pixel is not one Roblox could draw anyway. // // Not carried over: shadcn's `border border-transparent`. There it keeps an // unfocused switch the same size as a focused one under `box-sizing`; Vela // lowers a border onto a `UIStroke` drawn *on* the border, which changes no // size, so the token would buy an instance and nothing else. root: fv("h-4.5 w-8 rounded-full shadow-sm transition duration-150"), thumb: fv("size-4 rounded-full bg-background"),};
export type SwitchProps = { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; className?: ClassName;} & PassthroughProps<TextButton>;
const OWN_PROPS = ["checked", "defaultChecked", "onCheckedChange", "disabled", "className"] as const;
export function Switch(props: SwitchProps) { const [checked, setChecked] = useControllableState<boolean>({ value: props.checked, defaultValue: props.defaultChecked ?? false, onChange: props.onCheckedChange, });
const disabled = props.disabled === true;
// State classes sit inside the recipe's className slot, ahead of the // consumer's: resolution is last-token-wins, so anything appended after // `props.className` would be an override the consumer cannot undo. const className = switchVariants.root({ className: cn(checked ? "bg-primary" : "bg-input", disabled && "opacity-50", props.className), });
return ( <SwitchPrimitive.Root checked={checked} className={className} disabled={disabled} onCheckedChange={setChecked} {...toSlotProps(getPassthroughProps<TextButton>(props, OWN_PROPS))} > <SwitchPrimitive.Thumb className={cn(switchVariants.thumb())} /> </SwitchPrimitive.Root> );}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
| Prop | Type | Description |
|---|---|---|
| checked | boolean | Controlled value. Pass it with onCheckedChange. |
| defaultChecked | boolean | Uncontrolled starting value. Defaults to false. |
| onCheckedChange | (checked: boolean) => void | Fires on every change, controlled or not. |
| disabled | boolean | Blocks the press and adds opacity-50 to the recipe's className slot. |
| className | ClassName | Threaded 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 same mirrored-state
shape checkbox establishes — useControllableState here, the primitive driven controlled from it —
because Lattice keeps its context private and the track’s colour changes with the value.
The thumb has no position
Switch.Thumb owns AnchorPoint and Position, and animates them between the track’s edges for
whatever size the thumb turns out to be. The recipe says size-4 rounded-full bg-background and
stops.
Write a left-* or a translate-x-* here and you are fighting the primitive on a property it
rewrites every frame. If you want a different travel, change the thumb’s size or the track’s — the
geometry follows from those.
This is layout is an instance, not a property
seen from the other side: a Lattice primitive that owns a property is as load-bearing as a
UIListLayout that owns one, and the recipe stays out of both.
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 on each edge. Changing one means
changing the other; there is no variant that does it for you, because the file is short enough to
edit.
A className at the call site cannot do it either. h-5 w-9 is in the recipe, and the recipe
resolves after the props a call-site class arrives as — see
overriding from the call site.