@lattice-ui/react-switchStable directionimport Switchdepends on runtime, motionSwitch is the primitive for an on/off toggle: settings flips, feature enables, and any binary control. The root is the toggleable track that owns checked state, and the thumb slides between the two ends as the state changes. Everything visual — the track’s size and color, the thumb’s shape — is yours.
Reach for Switch when a control is boolean, should toggle on activation, and wants a thumb that animates between off and on positions. If you need a third "indeterminate" state or a reveal-style indicator instead of a sliding handle, Checkbox is usually the better fit.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
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 is the only required part. Thumb is the sliding handle; include it whenever you want the moving indicator.
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
The smallest switch that is actually visible: uncontrolled state seeded with defaultChecked, and just enough styling to see it. The root renders a textbutton and the thumb a frame, both unstyled, so the size and colors below are the minimum — without them 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 the switch needs to read or set the value — persisting a setting, syncing with a server, or resetting from elsewhere. 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 when you need a frame track rather than the textbutton the root renders, and on the thumb to slide your own handle. Track color is always yours — branch on the controlled state directly. The thumb still animates: its travel resolves to the track width minus the thumb width, whatever size you make either one.
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> );}Before 0.7.0 the root could animate its own BackgroundColor3 through trackColorMode, trackOnColor, trackOffColor and disabledTrackColor. Those props are gone, along with the SwitchTrackColorMode type. Derive the color from the checked state you already control, as above; if you want it to ease rather than snap, animate it yourself.
Disabled state
disabled blocks toggling entirely — activation is ignored and setChecked calls from context are dropped — and removes the switch from gamepad selection. Nothing about the appearance changes on its own, so render the inert look yourself. Here a premium-only option stays visible but inactive.
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 thumb’s motion has no transition prop — the slide is a fixed short settle — but the geometry adapts to your handle on its own. Travel resolves to the track width minus the thumb width, and the thumb stays vertically centered at any height, so you do not have to compute insets or match sizes.
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
Switch.Root is controllable on checked/onCheckedChange, with defaultChecked for uncontrolled usage (defaulting to false). The state is a plain boolean — activating the root flips it. When disabled, both the toggle and direct setChecked calls from context are ignored, so the state cannot change until the switch is re-enabled.
Activation and selection
Switch.Root renders an activatable textbutton that toggles on Activated, so click, tap, and gamepad activation all work. It carries no size, color or label of its own — pass those as props. It is Active and Selectable only while enabled, so a disabled switch drops out of gamepad selection.
With asChild, the toggle behavior is merged onto your single child element through the shared Slot: the slot’s Active, Selectable, and ref win over the child’s own props, and event handlers compose (both the slot’s Activated toggle and any handler you pass on the child run). Use an element that fires Activated, such as a textbutton or imagebutton.
Track color
The track’s BackgroundColor3 is entirely yours. Derive it from the same checked state you pass in, and animate it yourself if you want it to ease rather than snap.
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 two ends of the track as checked changes. Checked parks the thumb’s trailing edge on the track’s trailing edge; unchecked parks its leading edge on the leading edge. Because AnchorPoint and Position interpolate together, the travel resolves to the track width minus the thumb width for any thumb width — the primitive never needs to know how wide you made it, and a thumb sized through a child element, a size constraint, or a layout works as well as one with a declared Size. The same pairing on the Y axis keeps the thumb centered in the track at any height.
Motion owns AnchorPoint and Position under a layout target contract, so both are dropped from anything you pass rather than being written and clobbered on the next frame. Style the thumb with size, color, corners and children instead.
Under asChild, the primitive wraps your element in a transparent frame that it animates, and pins your element to Position (0, 0) inside that wrapper — so put your styling on the child, but leave its Position alone. The thumb is always mounted regardless of checked state; unlike Checkbox.Indicator it is not presence-driven, so there is no forceMount prop.
Motion
The thumb slide is a fixed short response settle — a 0.08s swift, responsive tween — so toggling feels immediate but smooth rather than snapping. Neither Root nor Thumb exposes a transition prop; you shape the feel through geometry instead. Nothing else on the switch animates: since 0.7.0 the track color is yours, so any color transition is yours to drive too.
The root is a Roblox button (textbutton, or your slotted element via asChild). It is made 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. |