@lattice-ui/react-switchStable directionimport Switchdepends on runtime, motionSwitch is the primitive for an on/off toggle: the root is the toggleable track that owns checked
state, and the thumb slides between its two ends. Everything visual is yours. For a third
"indeterminate" state or a reveal-style indicator, Checkbox
fits better.
Preview
import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";import { Switch } from "@lattice-ui/react-switch";
type SettingRow = { label: string; description: string; checked: boolean; onChange: (next: boolean) => void;};
function SwitchExample() { const { theme } = useTheme(); const [airplaneMode, setAirplaneMode] = React.useState(false); const [wifi, setWifi] = React.useState(true); const [bluetooth, setBluetooth] = React.useState(true);
const rows: Array<SettingRow> = [ { label: "Airplane Mode", description: "Disable all wireless connections.", checked: airplaneMode, onChange: setAirplaneMode, }, { label: "Wi-Fi", description: "Astra-Guest · connected", checked: wifi, onChange: setWifi, }, { label: "Bluetooth", description: "Visible to nearby devices.", checked: bluetooth, onChange: setBluetooth, }, ];
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[16])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[16])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[10])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(280, 40)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(280, 18)} Text="Quick settings" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 22)} Size={UDim2.fromOffset(280, 16)} Text="Manage device connectivity." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
{rows.map((row, index) => ( <frame BackgroundTransparency={1} key={row.label} LayoutOrder={index + 1} Size={UDim2.fromOffset(280, 44)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Position={UDim2.fromOffset(0, 3)} Size={UDim2.fromOffset(210, 18)} Text={row.label} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 23)} Size={UDim2.fromOffset(210, 16)} Text={row.description} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> {/* The track color is entirely consumer-owned: the primitive only reports state. */} <Switch.Root asChild checked={row.checked} onCheckedChange={row.onChange}> <textbutton AnchorPoint={new Vector2(1, 0.5)} AutoButtonColor={false} BackgroundColor3={row.checked ? theme.colors.accent : theme.colors.surface} BorderSizePixel={0} Position={new UDim2(1, 0, 0.5, 0)} Size={UDim2.fromOffset(44, 24)} Text="" > <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={theme.colors.border} Thickness={1} Transparency={row.checked ? 1 : 0} /> <Switch.Thumb asChild> <frame BackgroundColor3={theme.colors.accentContrast} BorderSizePixel={0} Size={UDim2.fromOffset(18, 18)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> </Switch.Thumb> </textbutton> </Switch.Root> </frame> ))} </frame> );}Import
import { Switch } from "@lattice-ui/react-switch";Anatomy
Root is the toggleable track and the only required part. Thumb is the sliding handle.
Switch anatomy
<Switch.Root> <Switch.Thumb /></Switch.Root>| Part | Required | Responsibility |
|---|---|---|
Switch.Root | yes | The track button: owns checked state and toggles on activation. |
Switch.Thumb | no | The handle that animates between the off and on ends of the track. |
Examples
Basic usage
Uncontrolled state seeded with defaultChecked, plus enough styling to see it. The root renders a
textbutton and the thumb a frame, both unstyled, so without a size and colours the switch works
but draws nothing.
import { Switch } from "@lattice-ui/react-switch";
export function BasicSwitch() { return ( <Switch.Root BackgroundColor3={Color3.fromRGB(66, 73, 91)} defaultChecked={true} onCheckedChange={(checked) => print(`switch is now: ${checked}`)} Size={UDim2.fromOffset(48, 24)} > <uicorner CornerRadius={new UDim(1, 0)} /> <Switch.Thumb BackgroundColor3={Color3.fromRGB(240, 244, 252)} Size={UDim2.fromOffset(20, 20)} > <uicorner CornerRadius={new UDim(1, 0)} /> </Switch.Thumb> </Switch.Root> );}Controlled settings toggle
Pass checked and onCheckedChange when something outside needs to read or set the value. Here a
music toggle keeps local state for instant feedback, and forwards every change to a save callback.
import { useState } from "@rbxts/react";import { Switch } from "@lattice-ui/react-switch";
export function MusicToggle(props: { onSave: (enabled: boolean) => void }) { const [enabled, setEnabled] = useState(true);
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(280, 36)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 12)} VerticalAlignment={Enum.VerticalAlignment.Center} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(100, 20)} Text="Music" TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} />
<Switch.Root BackgroundColor3={enabled ? Color3.fromRGB(86, 141, 255) : Color3.fromRGB(66, 73, 91)} checked={enabled} onCheckedChange={(checked) => { setEnabled(checked); props.onSave(checked); }} Size={UDim2.fromOffset(48, 24)} > <uicorner CornerRadius={new UDim(1, 0)} /> <Switch.Thumb BackgroundColor3={Color3.fromRGB(240, 244, 252)} Size={UDim2.fromOffset(20, 20)} > <uicorner CornerRadius={new UDim(1, 0)} /> </Switch.Thumb> </Switch.Root> </frame> );}Custom track and thumb with asChild
Use asChild on the root for a frame track rather than a textbutton, and on the thumb to slide
your own handle. Track colour is always yours. The thumb still animates: its travel resolves to the
track width minus the thumb width, whatever size you make either.
import { useState } from "@rbxts/react";import { Switch } from "@lattice-ui/react-switch";
export function StyledSwitch() { const [enabled, setEnabled] = useState(false);
return ( <Switch.Root checked={enabled} onCheckedChange={setEnabled} asChild> <frame BackgroundColor3={enabled ? Color3.fromRGB(52, 168, 108) : Color3.fromRGB(44, 48, 60)} BorderSizePixel={0} Size={UDim2.fromOffset(48, 24)} > <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={Color3.fromRGB(88, 142, 255)} Thickness={1} />
<Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(240, 244, 252)} BorderSizePixel={0} Size={UDim2.fromOffset(20, 20)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> </Switch.Thumb> </frame> </Switch.Root> );}The root never animates its own BackgroundColor3, and there are no track-colour props. Derive the colour from the checked state you already control.
Disabled state
disabled blocks toggling entirely and removes the switch from gamepad selection. Nothing about the
appearance changes on its own, so render the inert look yourself.
import { Switch } from "@lattice-ui/react-switch";
export function PremiumToggle(props: { hasPremium: boolean }) { const disabled = !props.hasPremium;
return ( <Switch.Root BackgroundColor3={disabled ? Color3.fromRGB(84, 90, 106) : Color3.fromRGB(66, 73, 91)} defaultChecked={false} disabled={disabled} Size={UDim2.fromOffset(48, 24)} > <uicorner CornerRadius={new UDim(1, 0)} /> <Switch.Thumb BackgroundColor3={Color3.fromRGB(240, 244, 252)} Size={UDim2.fromOffset(20, 20)} > <uicorner CornerRadius={new UDim(1, 0)} /> </Switch.Thumb> </Switch.Root> );}Custom thumb size
The slide is a fixed short settle with no transition prop, but the geometry adapts. Travel resolves to the track width minus the thumb width, and the thumb stays vertically centred at any height.
import { useState } from "@rbxts/react";import { Switch } from "@lattice-ui/react-switch";
export function LargeThumbSwitch() { const [enabled, setEnabled] = useState(false);
return ( <Switch.Root checked={enabled} onCheckedChange={setEnabled} asChild> <frame BackgroundColor3={Color3.fromRGB(66, 73, 91)} BorderSizePixel={0} Size={UDim2.fromOffset(64, 32)}> <uicorner CornerRadius={new UDim(1, 0)} />
<Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(240, 244, 252)} BorderSizePixel={0} Size={UDim2.fromOffset(28, 28)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> </Switch.Thumb> </frame> </Switch.Root> );}How it behaves
Checked state
Controllable on checked/onCheckedChange, with defaultChecked for uncontrolled usage. The state
is a plain boolean that activation flips. While disabled, both the toggle and direct setChecked
calls are ignored.
Activation and selection
Switch.Root renders an activatable textbutton that toggles on Activated. It carries no size,
colour or label of its own, and is Active and Selectable only while enabled.
Under asChild the slot’s Active, Selectable and ref win over the child’s props, and event
handlers compose. Use an element that fires Activated.
Track color
The track’s BackgroundColor3 is entirely yours — derive it from the same checked state you pass
in.
trackColorMode, trackOnColor, trackOffColor and disabledTrackColor no longer exist on Switch.Root, and neither does the SwitchTrackColorMode type. See Migration.
The thumb
Switch.Thumb animates between the ends of the track as checked changes. Checked parks its
trailing edge on the track’s trailing edge, unchecked its leading edge on the leading edge.
AnchorPoint and Position interpolate together, so travel resolves to the track width minus the
thumb width for any thumb width. The same pairing on Y keeps it centred at any height.
Motion owns AnchorPoint and Position under a layout target contract, so both are dropped from
anything you pass. Style the thumb with size, colour, corners and children.
Under asChild the primitive wraps your element in a transparent frame it animates and pins your
element to Position (0, 0) inside it. The thumb is always mounted, so there is no forceMount.
Motion
The slide is a fixed 0.08s settle. Neither Root nor Thumb exposes a transition — you shape the
feel through geometry. Nothing else animates.
The root is Active and Selectable only while enabled, so a disabled switch drops out of gamepad selection. Give the track a real size — the thumb’s travel is measured against it.
API reference
Switch.Root
| Prop | Type | Description |
|---|---|---|
| checked | boolean | Controlled checked state. Pair with onCheckedChange. |
| defaultChecked | boolean | Initial checked state for uncontrolled usage. Defaults to false. |
| onCheckedChange | (checked: boolean) => void | Called whenever the checked state changes. |
| disabled | boolean | Prevents toggling and removes the switch from gamepad selection. Defaults to false. |
| asChild | boolean | Merge the track behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| children | React.ReactNode | The track contents, typically a Switch.Thumb. Must be a single valid element when asChild is set. |
| …TextButton props | Partial<WritableInstanceProperties<TextButton>> | Forwarded onto the rendered textbutton and type-checked against it. Active and Selectable are owned by the primitive, derived from disabled. |
Switch.Thumb
Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error. The primitive owns AnchorPoint and Position under a layout motion contract, so values you pass for those are ignored.
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the animated thumb onto the single child element instead of the frame the part renders. The child is pinned at Position (0, 0) inside the animated wrapper. |
| children | React.ReactNode | The thumb contents. Must be a single valid element when asChild is set. |
| …Frame props | Partial<WritableInstanceProperties<Frame>> | Forwarded onto the rendered frame and type-checked against it. AnchorPoint and Position are dropped — motion owns the thumb's placement. Travel is derived from the track and thumb widths, so no Size is required. |