Latticecomponents

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.

@lattice-ui/react-radio-groupStable directionimport RadioGroupdepends 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 is usually the better fit.

Preview

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

Edit

Import

import { RadioGroup } from "@lattice-ui/react-radio-group";

Anatomy

Wrap a set of Items 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).

RadioGroup anatomy

RadioGroup anatomy
<RadioGroup.Root>
<RadioGroup.Item value="...">
<RadioGroup.Indicator />
</RadioGroup.Item>
</RadioGroup.Root>
PartRequiredResponsibility
RadioGroup.RootyesOwns the selected value, group state, item registry, and directional selection movement. Renders no instance.
RadioGroup.ItemyesA selectable button bound to a value; activates, registers as a focus node, and drives arrow navigation.
RadioGroup.IndicatornoPresence-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.

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.

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.

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.

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.

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

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

API reference

RadioGroup.Root

PropTypeDescription
valuestringControlled selected value. Pair with onValueChange.
defaultValuestringInitial selected value for uncontrolled usage. Omit to start with nothing selected.
onValueChange(value: string) => voidCalled when the selected value changes. Never called with undefined.
disabledbooleanDisables the whole group; activation, selection, and movement are ignored. Defaults to false.
requiredbooleanMarks 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".
childrenReact.ReactNodeThe radio items. Root renders no instance of its own, so children mount into the surrounding container.

RadioGroup.Item

PropTypeDescription
valuestringThe value this item represents. Required; compared against the group value to determine checked state.
disabledbooleanDisables this item; it cannot be selected and is skipped by arrow and gamepad navigation. Defaults to false.
asChildbooleanMerge item behavior onto the single child element via Slot instead of the textbutton the part renders. The child must be an activatable button.
childrenReact.ReactNodeContent placed inside the item button, such as an Indicator. Must be a single element when asChild is set.

RadioGroup.Indicator

PropTypeDescription
transitionPresenceMotionConfigReveal/exit motion. None by default; pass createIndicatorRevealRecipe(size) built at the size your indicator settles at.
forceMountbooleanKeeps the indicator mounted regardless of checked state, bypassing the presence wrapper so you drive visibility yourself. Defaults to false.
asChildbooleanRender your own marker element instead of the frame the part renders. The child's Visible property is bound to presence.
childrenReact.ReactNodeThe marker content shown while the item is checked.