# Switch

> Boolean toggle primitive that owns checked state and animates the thumb between the ends of the track, while your component owns every part of how it looks.

Source: https://docs.astra-void.xyz/lattice-ui/components/switch/

`@lattice-ui/react-switch` · Stable direction · import `Switch` · depends on `runtime`, `motion`

Switch 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](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md) is usually the better fit.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

_Interactive preview._

## Import

```ts
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.

```tsx title="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.

```tsx title="BasicSwitch.tsx"
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.

```tsx title="MusicToggle.tsx"
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.

```tsx title="StyledSwitch.tsx"
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>
  );
}
```

> **Track color is yours**
>
> 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.

```tsx title="PremiumToggle.tsx"
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.

```tsx title="LargeThumbSwitch.tsx"
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.

> **Removed in 0.7.0**
>
> `trackColorMode`, `trackOnColor`, `trackOffColor` and `disabledTrackColor` no longer exist on `Switch.Root`, and neither does the `SwitchTrackColorMode` type. See [Migration](https://docs.astra-void.xyz/lattice-ui/reference/migration.md).

### 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.

> **Roblox gotchas**
>
> 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. |

## Related

- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
