# Toggle Group

> Selection primitive that coordinates a set of toggle items in single- or multiple-select mode, owning pressed state and motion while you own the item visuals.

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

`@lattice-ui/react-toggle-group` · Stable direction · import `ToggleGroup` · depends on `runtime`, `focus`, `motion`

Toggle Group is the primitive for a related set of toggles that share selection state: view switches, filter chips, text-alignment pickers, and difficulty selectors. `Root` owns the selected value and tells each `Item` whether it is pressed, so your items only render their two visual states.

Reach for Toggle Group when several toggles belong together and should behave as **one control** — either a **single-select** group where picking one clears the others, or a **multiple-select** group where each item toggles independently. For a lone on/off control, [Checkbox](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md) or [Switch](https://docs.astra-void.xyz/lattice-ui/components/switch.md) is 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 { ToggleGroup } from "@lattice-ui/react-toggle-group";
```

## Anatomy

A `Root` wrapping one `Item` per option. `Root` requires a `type` that fixes the whole group as single- or multiple-select.

```tsx title="ToggleGroup anatomy"
<ToggleGroup.Root type="single">
  <ToggleGroup.Item value="..." />
  <ToggleGroup.Item value="..." />
</ToggleGroup.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `ToggleGroup.Root` | yes | Owns the selected value(s), the select mode, and group-wide disabled state. |
| `ToggleGroup.Item` | yes | One toggle; reads its pressed state from `Root`. |

## Examples

### Single-select toolbar

The smallest useful group: an uncontrolled single-select mode picker seeded with `defaultValue`. Each default item renders as a `textbutton` labeled with its `value`, which makes it easy to wire the group up before styling anything. In single mode, activating the already-pressed item clears the selection — `onValueChange` then fires with `undefined` — so a toolbar like this can also be fully deselected.

```tsx title="HotbarModePicker.tsx"
import { ToggleGroup } from "@lattice-ui/react-toggle-group";

export function HotbarModePicker() {
  return (
    <ToggleGroup.Root
      type="single"
      defaultValue="build"
      onValueChange={(mode) => print(`active mode: ${mode}`)}
    >
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 6)}
      />
      <ToggleGroup.Item value="build" />
      <ToggleGroup.Item value="terrain" />
      <ToggleGroup.Item value="wire" />
    </ToggleGroup.Root>
  );
}
```

### Multiple-select filters

Switch `type` to `"multiple"` and the value becomes a `string[]`: each item toggles independently, and the array preserves the order in which items were selected. `defaultValue` seeds the initial set for uncontrolled usage (it defaults to an empty array), and `onValueChange` receives the full next array on every toggle.

```tsx title="ItemFilters.tsx"
import { ToggleGroup } from "@lattice-ui/react-toggle-group";

export function ItemFilters() {
  return (
    <ToggleGroup.Root
      type="multiple"
      defaultValue={["new"]}
      onValueChange={(filters) => print(`active filters: ${filters.join(", ")}`)}
    >
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 6)}
      />
      <ToggleGroup.Item value="new" />
      <ToggleGroup.Item value="owned" />
      <ToggleGroup.Item value="tradable" />
    </ToggleGroup.Root>
  );
}
```

### Controlled group

Pass `value` and `onValueChange` when something outside the group reads or sets the selection. Here a start button reads the picked difficulty and stays inert while the selection is cleared — in single mode the controlled value is `string | undefined`, so design the surrounding UI around the `undefined` case.

```tsx title="DifficultyPicker.tsx"
import { useState } from "@rbxts/react";
import { ToggleGroup } from "@lattice-ui/react-toggle-group";

export function DifficultyPicker(props: { onStart: (difficulty: string) => void }) {
  const [difficulty, setDifficulty] = useState<string | undefined>("normal");

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(320, 90)}>
      <uilistlayout Padding={new UDim(0, 10)} SortOrder={Enum.SortOrder.LayoutOrder} />

      <ToggleGroup.Root type="single" value={difficulty} onValueChange={setDifficulty}>
        <uilistlayout
          FillDirection={Enum.FillDirection.Horizontal}
          Padding={new UDim(0, 6)}
        />
        <ToggleGroup.Item value="casual" />
        <ToggleGroup.Item value="normal" />
        <ToggleGroup.Item value="hardcore" />
      </ToggleGroup.Root>

      <textbutton
        Active={difficulty !== undefined}
        AutoButtonColor={difficulty !== undefined}
        BackgroundColor3={
          difficulty !== undefined ? Color3.fromRGB(88, 142, 255) : Color3.fromRGB(59, 66, 84)
        }
        Event={{
          Activated: () => {
            if (difficulty !== undefined) {
              props.onStart(difficulty);
            }
          },
        }}
        Size={UDim2.fromOffset(160, 32)}
        Text={difficulty !== undefined ? `Start (${difficulty})` : "Pick a difficulty"}
        TextColor3={Color3.fromRGB(240, 244, 252)}
      />
    </frame>
  );
}
```

### Disabled group and disabled items

`disabled` works at two levels. On `Root` it freezes the whole control: every item ignores activation, and `Root`'s toggle logic is short-circuited so nothing can change the value while it is set. On an `Item` it disables just that option while the rest of the group keeps working. Here the loadout is locked during a match, and one class is additionally locked behind premium.

```tsx title="LoadoutModes.tsx"
import { ToggleGroup } from "@lattice-ui/react-toggle-group";

export function LoadoutModes(props: { inMatch: boolean; hasPremium: boolean }) {
  return (
    <ToggleGroup.Root type="single" defaultValue="assault" disabled={props.inMatch}>
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 6)}
      />
      <ToggleGroup.Item value="assault" />
      <ToggleGroup.Item value="recon" />
      <ToggleGroup.Item value="specialist" disabled={!props.hasPremium} />
    </ToggleGroup.Root>
  );
}
```

A disabled item is marked inactive (`Active={false}`) but still renders its pressed state, so a locked-in selection stays visibly selected.

### Custom pressed visuals with asChild

Use `asChild` on `Root` when you need a different container class, and on each `Item` to project the toggle behavior onto your own button. Since 0.7.0 the primitive never writes an item's colors, so read the pressed state from `Root` and set `BackgroundColor3` and `TextColor3` yourself (see [How it behaves](#item-colors)).

```tsx title="StyledViewSwitch.tsx"
import { useState } from "@rbxts/react";
import { ToggleGroup } from "@lattice-ui/react-toggle-group";

const VIEWS = ["grid", "list", "detail"];

export function StyledViewSwitch() {
  const [view, setView] = useState<string | undefined>("grid");

  return (
    <ToggleGroup.Root type="single" value={view} onValueChange={setView} asChild>
      <frame
        BackgroundColor3={Color3.fromRGB(24, 26, 32)}
        BorderSizePixel={0}
        Size={UDim2.fromOffset(248, 44)}
      >
        <uicorner CornerRadius={new UDim(0, 8)} />
        <uilistlayout
          FillDirection={Enum.FillDirection.Horizontal}
          HorizontalAlignment={Enum.HorizontalAlignment.Center}
          Padding={new UDim(0, 6)}
          VerticalAlignment={Enum.VerticalAlignment.Center}
        />

        {VIEWS.map((viewName) => (
          <ToggleGroup.Item value={viewName} key={viewName} asChild>
            <textbutton AutoButtonColor={false} Size={UDim2.fromOffset(76, 32)} Text={viewName} TextSize={14}>
              <uicorner CornerRadius={new UDim(0, 6)} />
              <uistroke
                Color={Color3.fromRGB(88, 142, 255)}
                Thickness={view === viewName ? 1 : 0}
              />
            </textbutton>
          </ToggleGroup.Item>
        ))}
      </frame>
    </ToggleGroup.Root>
  );
}
```

`ToggleGroup.Item` no longer accepts a `transition` as of 0.7.0 — the prop existed only to time the color animation that has been removed. Animate your own colors if you want the change to ease.

## How it behaves

### Value state

`ToggleGroup.Root` is controllable. Pass `value` and `onValueChange` to control the selection, or omit them and pass `defaultValue` to let the group own it — `onValueChange` still fires on every change in uncontrolled usage. Each `Item` reads its own pressed state from the group through context, so controlled and uncontrolled usage behave identically. Activating an item routes through `Root`, which updates the shared value.

### Single vs multiple

The required `type` prop fixes the group's behavior and the shape of its value:

- **`type="single"`** — `value`/`defaultValue` are `string`, and `onValueChange` receives `string | undefined`. Selecting an item replaces the current value; selecting the already-selected item clears it, so the value becomes `undefined`.
- **`type="multiple"`** — `value`/`defaultValue` are `string[]`, and `onValueChange` receives `string[]`. Each item toggles independently: a newly selected value is appended to the end, so the array preserves selection order. Incoming values (controlled `value`, `defaultValue`, and the array handed to `onValueChange`) are normalized — duplicate and non-string entries are stripped.

Because the value type and the `onValueChange` signature are a discriminated union over `type`, set `type` as a literal so TypeScript can narrow to the right props.

### Activation and selection

`ToggleGroup.Item` toggles on `Activated` and on `Return`/`Space` via `InputBegan`, so mouse, touch, keyboard, and gamepad activation all work. It renders an unstyled `textbutton` with `AutoButtonColor` off, and does **not** render `value` as its text — pass a `Text` of your own. Items are rendered with `Selectable={false}` on the button itself — manage gamepad selection at the surrounding layout if you need it.

With `asChild`, the toggle behavior is merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, and motion ref win over the child's own props, and event handlers compose (both the primitive's `Activated`/`InputBegan` handlers and any you pass on the child run). Use a `textbutton` or `imagebutton` so `Activated` fires.

### Group container

`Root` renders a transparent, borderless frame with no size of its own — it is a context boundary, not a laid-out surface. Put a `uilistlayout` (or your own positioning) inside it to arrange the items, and pass a `Size` so it reserves space in a flow layout.

### Disabled state

`disabled` on `Root` disables the entire group; `disabled` on an `Item` disables just that one. A disabled item ignores both activation and keyboard toggles and is marked inactive (`Active={false}`), but keeps rendering its pressed visuals. Group-level disabled additionally short-circuits `Root`'s toggle logic, so no value change can get through while it is set — including from an item that missed the flag.

### Item colors

An item's `BackgroundColor3` and `TextColor3` are entirely yours as of 0.7.0. Each item used to animate between fixed active and inactive palettes with no way to opt out; the primitive now writes neither, in either mode. Read the pressed state from `ToggleGroup.Root` and branch on it, adding your own response motion if you want the change to ease rather than snap.

> **Single mode can clear to undefined**
>
> Activating the pressed item in a single-select group deselects it; there is no built-in "always keep one selected" mode. If your UI requires a selection, run controlled and ignore the clear: `onValueChange={(next) => next !== undefined && setValue(next)}`.

> **The container has no size of its own**
>
> The `Root` frame is transparent and unsized, so it reserves no space in a `uilistlayout` or `uigridlayout` parent. Pass a `Size` — or `asChild` with your own container — whenever the group sits in a flow layout.

> **Removed in 0.7.0**
>
> Before 0.7.0 `ToggleGroup.Item` animated fixed active/inactive palettes that could not be overridden, and took a `transition` to time them. Both are gone — the item writes no colors and accepts no `transition`. Colors you set stay exactly as you set them.

## API reference

### ToggleGroup.Root

`Root` takes the common props below plus the single- or multiple-mode props selected by `type`.

| Prop | Type | Description |
| --- | --- | --- |
| `type` | `"single" \| "multiple"` | Required. Fixes the group as single- or multiple-select and determines the value shape. Pass a literal so TypeScript narrows the union. |
| `value` | `string \| string[]` | Controlled selection. string in single mode, string[] (normalized: duplicates and non-strings stripped) in multiple mode. Pair with onValueChange. |
| `defaultValue` | `string \| string[]` | Initial selection for uncontrolled usage. string in single mode; string[] (default []) in multiple mode. |
| `onValueChange` | `(value: string \| undefined) => void \| (value: string[]) => void` | Called on every selection change, controlled or not. Receives string \| undefined in single mode and the full next string[] in multiple mode. |
| `disabled` | `boolean` | Disables every item and short-circuits the group's toggle logic so no value change gets through. Defaults to false. |
| `asChild` | `boolean` | Render the single child element as the group container instead of the frame the part renders. |
| `children` | `React.ReactNode` | The toggle items and any layout. Must be a single valid element when asChild is set. |

### ToggleGroup.Item

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Required. Identifies this item within the group. Since 0.7.0 it is not rendered as the button's text — pass Text yourself. |
| `disabled` | `boolean` | Disables just this item — activation and keyboard toggles are ignored and the button is marked inactive. Defaults to false. |
| `asChild` | `boolean` | Merge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| `children` | `React.ReactNode` | Rendered inside the item button, or the element to project onto. Must be a single element when asChild is set. |

## 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)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
- [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md)
