# Radio Group

> Single-selection primitive that owns the chosen value, registers items in order, and moves selection across them with gamepad and arrow-key navigation.

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

`@lattice-ui/react-radio-group` · Stable direction · import `RadioGroup` · depends on `runtime`, `focus`, `layer`, `motion`

Radio Group is the primitive for picking exactly one option from a set: difficulty selectors, team pickers, quality settings, and any mutually-exclusive choice. `Root` owns the selected value; `Item` registers itself in the group, reflects whether it is checked, and handles activation and directional navigation; `Indicator` reveals a marker on the selected item.

Reach for Radio Group when a choice is **single-select**, the options should be **navigable as a unit** with the arrow keys or a gamepad, and selection should **follow focus** the way native radio groups do. For a choice that lives behind a trigger and opens in a popup, [Select](https://docs.astra-void.xyz/lattice-ui/components/select.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 { RadioGroup } from "@lattice-ui/react-radio-group";
```

## Anatomy

Wrap a set of `Item`s in a `Root`. Each `Item` carries a `value` and may contain an `Indicator` that shows only while that item is checked. `Root` renders no instance of its own — it only provides context — so the surrounding container and layout are yours (see [How it behaves](#root-renders-no-container)).

```tsx title="RadioGroup anatomy"
<RadioGroup.Root>
  <RadioGroup.Item value="...">
    <RadioGroup.Indicator />
  </RadioGroup.Item>
</RadioGroup.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `RadioGroup.Root` | yes | Owns the selected value, group state, item registry, and directional selection movement. Renders no instance. |
| `RadioGroup.Item` | yes | A selectable button bound to a `value`; activates, registers as a focus node, and drives arrow navigation. |
| `RadioGroup.Indicator` | no | Presence-driven marker rendered only while its parent item is checked. |

## Examples

### Basic usage

The smallest possible group: uncontrolled state seeded with `defaultValue` and the default item visuals. Each default item is a `textbutton` labeled with its own `value` and tinted by whether it is checked, which makes it useful for wiring things up before you style anything. Because `Root` renders no container, the group borrows the layout of whatever frame it sits in.

```tsx title="BasicRadioGroup.tsx"
import { RadioGroup } from "@lattice-ui/react-radio-group";

export function BasicRadioGroup() {
  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(170, 120)}>
      <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />

      <RadioGroup.Root
        defaultValue="Normal"
        onValueChange={(value) => print(`selected: ${value}`)}
      >
        <RadioGroup.Item value="Easy" />
        <RadioGroup.Item value="Normal" />
        <RadioGroup.Item value="Hard" />
      </RadioGroup.Root>
    </frame>
  );
}
```

### Controlled group

Pass `value` and `onValueChange` when something outside the group needs to read or set the choice — persisting a setting, syncing with a server, or resetting from elsewhere. `onValueChange` fires with the newly selected value and never with `undefined`, so it is safe to store directly.

```tsx title="QualitySetting.tsx"
import { useState } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";

const QUALITIES = ["Low", "Medium", "High", "Ultra"];

export function QualitySetting(props: { onSave: (quality: string) => void }) {
  const [quality, setQuality] = useState("Medium");

  const selectQuality = (nextQuality: string) => {
    setQuality(nextQuality);
    props.onSave(nextQuality);
  };

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

      <RadioGroup.Root value={quality} onValueChange={selectQuality}>
        {QUALITIES.map((option) => (
          <RadioGroup.Item key={option} value={option} />
        ))}
      </RadioGroup.Root>
    </frame>
  );
}
```

### Orientation and arrow keys

`orientation` picks the arrow-key axis: a vertical group (the default) moves on `Up`/`Down`, a horizontal group on `Left`/`Right`. The prop does not lay the items out — pair it with a `uilistlayout` whose `FillDirection` matches so the keys move the way the group looks. Movement clamps at both ends rather than wrapping, so holding `Right` on the last item keeps it selected.

```tsx title="TeamPicker.tsx"
import { useState } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";

const TEAMS = ["Red", "Blue", "Green"];

export function TeamPicker() {
  const [team, setTeam] = useState("Red");

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(540, 34)}>
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 8)}
        SortOrder={Enum.SortOrder.LayoutOrder}
      />

      <RadioGroup.Root value={team} onValueChange={setTeam} orientation="horizontal">
        {TEAMS.map((option) => (
          <RadioGroup.Item key={option} value={option} />
        ))}
      </RadioGroup.Root>
    </frame>
  );
}
```

### Card-style items with asChild

Use `asChild` to project the item behavior — activation, gamepad selection, and the arrow-key wiring — onto your own button, and place an `Indicator` inside it as the selection dot. The item no longer touches your element's color, so branch on the checked state yourself. The indicator only animates if you give it a `transition`.

```tsx title="DifficultyCards.tsx"
import { useState } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";

const OPTIONS = ["Easy", "Normal", "Hard"];

export function DifficultyCards() {
  const [difficulty, setDifficulty] = useState("Normal");

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

      <RadioGroup.Root value={difficulty} onValueChange={setDifficulty}>
        {OPTIONS.map((option) => (
          <RadioGroup.Item key={option} value={option} asChild>
            <textbutton AutoButtonColor={false} Size={UDim2.fromOffset(220, 44)} Text="">
              <uicorner CornerRadius={new UDim(0, 8)} />
              <uistroke Color={Color3.fromRGB(88, 142, 255)} Thickness={1} />
              <uilistlayout
                FillDirection={Enum.FillDirection.Horizontal}
                Padding={new UDim(0, 10)}
                VerticalAlignment={Enum.VerticalAlignment.Center}
              />
              <uipadding PaddingLeft={new UDim(0, 12)} />

              <frame
                BackgroundColor3={Color3.fromRGB(20, 22, 28)}
                Size={UDim2.fromOffset(18, 18)}
              >
                <uicorner CornerRadius={new UDim(1, 0)} />
                <RadioGroup.Indicator asChild>
                  <frame
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundColor3={Color3.fromRGB(240, 244, 252)}
                    Position={UDim2.fromScale(0.5, 0.5)}
                    Size={UDim2.fromOffset(10, 10)}
                  >
                    <uicorner CornerRadius={new UDim(1, 0)} />
                  </frame>
                </RadioGroup.Indicator>
              </frame>

              <textlabel
                BackgroundTransparency={1}
                Size={UDim2.fromOffset(160, 20)}
                Text={option}
                TextColor3={Color3.fromRGB(236, 241, 249)}
                TextXAlignment={Enum.TextXAlignment.Left}
              />
            </textbutton>
          </RadioGroup.Item>
        ))}
      </RadioGroup.Root>
    </frame>
  );
}
```

### Disabled items

`disabled` on an item makes it inert and removes it from navigation entirely: it cannot be activated, it drops out of gamepad selection, and arrow movement resolves the next item from the enabled entries only — so stepping "through" a disabled item lands on the next enabled one. Setting `disabled` on `Root` freezes the whole group instead. Here a locked mode stays visible but cannot be chosen.

```tsx title="GameModePicker.tsx"
import { useState } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";

const MODES = [
  { value: "Classic", locked: false },
  { value: "Ranked", locked: true },
  { value: "Endless", locked: false },
];

export function GameModePicker() {
  const [mode, setMode] = useState("Classic");

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

      <RadioGroup.Root value={mode} onValueChange={setMode}>
        {MODES.map((entry) => (
          <RadioGroup.Item key={entry.value} value={entry.value} disabled={entry.locked} />
        ))}
      </RadioGroup.Root>
    </frame>
  );
}
```

### Dynamic item lists

Items register with the group on mount and unregister on unmount, and directional navigation walks that registry in **mount order** (see [How it behaves](#item-registration-order)). For a list that grows at runtime, appending to the end keeps mount order and visual order aligned; inserting into the middle of the array renders in the middle but navigates last, so prefer append-only growth or a `LayoutOrder` that mirrors mount order.

```tsx title="LoadoutPicker.tsx"
import { useState } from "@rbxts/react";
import { RadioGroup } from "@lattice-ui/react-radio-group";

export function LoadoutPicker() {
  const [loadouts, setLoadouts] = useState(["Scout", "Tank"]);
  const [selected, setSelected] = useState("Scout");

  const addLoadout = () => {
    setLoadouts([...loadouts, `Custom ${loadouts.size() - 1}`]);
  };

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

      <RadioGroup.Root value={selected} onValueChange={setSelected}>
        {loadouts.map((loadout) => (
          <RadioGroup.Item key={loadout} value={loadout} />
        ))}
      </RadioGroup.Root>

      <textbutton
        BackgroundColor3={Color3.fromRGB(47, 53, 68)}
        Event={{ Activated: addLoadout }}
        Size={UDim2.fromOffset(170, 30)}
        Text="+ New loadout"
        TextColor3={Color3.fromRGB(236, 241, 249)}
      />
    </frame>
  );
}
```

## How it behaves

### Value and selection

`RadioGroup.Root` is controllable on `value`/`onValueChange`, with `defaultValue` for uncontrolled usage. With no `defaultValue`, the group starts with nothing selected until an item is activated. An `Item` is checked when its `value` equals the group value, and `onValueChange` is only ever called with a defined string — there is no way to clear the selection through interaction, matching native radio behavior.

Activating an item calls the group's `setValue` with that item's value. When `Root` is `disabled`, `setValue` drops every change, so neither activation nor programmatic movement can alter the value; an individual `disabled` item just refuses its own activation.

### Activation

An item selects itself on any of three inputs: `Activated` (click or tap), `Return`/`Space` via `InputBegan`, and `SelectionGained` — meaning that on a gamepad, merely moving selection onto an item commits it. The item is `Active` and `Selectable` only while enabled, so a disabled item drops out of gamepad selection and cannot fire any of these paths.

With `asChild`, these handlers are 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. Use a `textbutton` or `imagebutton` so `Activated` fires.

### Orientation and arrow-key navigation

`Root` takes an `orientation` of `"vertical"` (default) or `"horizontal"`, which selects the arrow-key axis each item listens on: `Up`/`Down` in a vertical group, `Left`/`Right` in a horizontal one; the other axis is ignored. A matching arrow press asks the group to move selection one step: the group resolves the next entry from the ordered registry, focuses it through the [focus manager](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md), and selects it in the same step — selection follows focus.

Movement is computed over **available** entries only — an entry is skipped if it is disabled, its instance is not `Visible`, or it is not `Selectable`. The step index is clamped to the ends of that list, so movement never wraps: pressing past the last item is a no-op. If the currently selected item is itself unavailable (say it became disabled), the next forward move lands on the first available item and the next backward move on the last.

### Item registration order

Items register themselves with `Root` on mount and unregister on unmount, each stamped with a monotonically increasing `order` on its first render. An item keeps its order for its whole lifetime; remounting assigns a fresh, higher one. Directional navigation sorts the registry by this order, so navigation tracks **mount order rather than visual position**. Keep the two aligned: render items from one array, append new entries at the end, and lay them out with a `uilistlayout` whose `SortOrder` follows the same sequence.

### Item rendering and color

An `Item` renders an unstyled `textbutton` — no size, no label, no colors. It does not render `value` as its text; pass a `Text` of your own, or children.

Its `BackgroundColor3` is yours as of 0.7.0. The item used to animate between a fixed accent palette with no opt-out, which is also why `RadioGroup.Item` no longer accepts a `transition` — that prop existed only to time the removed color animation. Derive the color from the checked state and animate it yourself if you want it to ease.

### Indicator presence and motion

`RadioGroup.Indicator` is presence-driven by its parent item's checked state: it mounts when the item becomes checked and unmounts when another item is chosen, holding through any exit transition first. It runs no motion of its own — pass `createIndicatorRevealRecipe(size)` from `@lattice-ui/react-motion` as `transition`, built at the size your dot settles at, to grow it from zero while fading in. Pass `forceMount` to keep the indicator mounted regardless of checked state, bypassing the presence wrapper so you can drive visibility yourself. With `asChild`, your child element is rendered in place of the frame the part renders and its `Visible` property is bound to the presence state.

### Root renders no container

`RadioGroup.Root` renders no instance — it only provides context — so its children are parented to whatever frame surrounds it. Supply the container and `uilistlayout` yourself, which also means one group's items can share a layout with neighboring elements (a heading, a divider) without extra nesting.

> **Selection follows focus**
>
> On a gamepad, moving selection onto an item selects it immediately (via `SelectionGained`), and arrow navigation both focuses and selects in one step. This matches the native radio pattern, but it means a group should not be the default-selected element if you do not want the first hover to commit a choice.

> **Arrow movement clamps at the ends**
>
> Directional movement clamps to the first and last available items instead of wrapping around. Pressing past either end keeps the current selection, and disabled or invisible items are stepped over as if they were not there.

> **The primitive tints the item**
>
> Before 0.7.0 the item drove your element's `BackgroundColor3` between fixed checked and unchecked accents, and `transition` timed that animation. Both are gone: the item never writes color, and `RadioGroup.Item` no longer accepts `transition`. A background you set stays put.

## API reference

### RadioGroup.Root

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled selected value. Pair with onValueChange. |
| `defaultValue` | `string` | Initial selected value for uncontrolled usage. Omit to start with nothing selected. |
| `onValueChange` | `(value: string) => void` | Called when the selected value changes. Never called with undefined. |
| `disabled` | `boolean` | Disables the whole group; activation, selection, and movement are ignored. Defaults to false. |
| `required` | `boolean` | Marks selection as required for your own form/validation wiring. Exposed on context; does not change interaction on its own. Defaults to false. |
| `orientation` | `"horizontal" \| "vertical"` | Axis for arrow-key navigation: Up/Down when vertical, Left/Right when horizontal. Defaults to "vertical". |
| `children` | `React.ReactNode` | The radio items. Root renders no instance of its own, so children mount into the surrounding container. |

### RadioGroup.Item

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The value this item represents. Required; compared against the group value to determine checked state. |
| `disabled` | `boolean` | Disables this item; it cannot be selected and is skipped by arrow and gamepad navigation. Defaults to false. |
| `asChild` | `boolean` | Merge item behavior onto the single child element via Slot instead of the textbutton the part renders. The child must be an activatable button. |
| `children` | `React.ReactNode` | Content placed inside the item button, such as an Indicator. Must be a single element when asChild is set. |

### RadioGroup.Indicator

| Prop | Type | Description |
| --- | --- | --- |
| `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createIndicatorRevealRecipe(size) built at the size your indicator settles at. |
| `forceMount` | `boolean` | Keeps the indicator mounted regardless of checked state, bypassing the presence wrapper so you drive visibility yourself. Defaults to false. |
| `asChild` | `boolean` | Render your own marker element instead of the frame the part renders. The child's Visible property is bound to presence. |
| `children` | `React.ReactNode` | The marker content shown while the item is checked. |

## Related

- [Controllable state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.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)
