Latticecomponents

Radio Group

Single selection across registered items, with gamepad and arrow-key movement.

@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. Root owns the value, Item registers itself and handles activation and directional navigation, and Indicator marks the selected one. Selection follows focus, the way native radio groups do. For a choice behind a trigger, Select fits better.

Preview

Edit

Import

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

Anatomy

Each Item carries a value and may contain an Indicator that shows only while checked. Root renders no instance, so the container and layout are yours.

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

Uncontrolled state seeded with defaultValue. With no container of its own, the group borrows the layout of the 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 needs to read or set the choice. It fires with the newly selected value and never with undefined.

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: vertical (the default) moves on Up/Down, horizontal on Left/Right. It does not lay the items out — pair it with a matching uilistlayout FillDirection. Movement clamps at both ends.

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 activation, gamepad selection and the arrow-key wiring onto your own button, with an Indicator inside as the dot. The item never touches your element’s colour.

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. It cannot be activated, drops out of gamepad selection, and arrow movement steps over it. disabled on Root freezes the whole group.

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

Directional navigation walks the registry in mount order. Appending keeps mount and visual order aligned. Inserting into the middle renders in the middle but navigates last, so prefer append-only growth, or a LayoutOrder mirroring 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

Controllable on value/onValueChange, with defaultValue for uncontrolled usage. With neither, the group starts empty. An Item is checked when its value equals the group value. onValueChange only ever receives a defined string, so interaction cannot clear the selection, matching native radio behaviour.

Activating an item calls the group’s setValue. A disabled Root drops every change, so neither activation nor programmatic movement alters the value. An individual disabled item only refuses its own activation.

Activation

An item selects itself on Activated, on Return/Space and on SelectionGained, so on a gamepad moving selection onto an item commits it. An item is Active and Selectable only while enabled.

With asChild these handlers merge onto your child element through Slot: the slot’s Active, Selectable and ref win, and handlers compose. Use a textbutton or imagebutton so Activated fires.

Orientation and arrow-key navigation

orientation selects the arrow-key axis each item listens on, and the other is ignored. A matching press resolves the next entry from the ordered registry, focuses it through the focus manager, and selects it in the same step.

Movement covers available entries only, skipping disabled, invisible or non-Selectable ones, and clamps at the ends. If the selected item is itself unavailable, the next forward move lands on the first available item and the next backward move on the last.

Item registration order

Each item is stamped with a monotonically increasing order on its first render and keeps it for its lifetime. Remounting assigns a fresh, higher one. Navigation sorts by that order, so it tracks mount order rather than visual position. Render from one array, append new entries, and lay them out with a matching SortOrder.

Item rendering and color

An Item renders an unstyled textbutton with no size, label or colours, and does not render value as its text. Pass a Text of your own, or children.

The item never writes BackgroundColor3, which is why it accepts no transition — that prop only ever timed a colour animation the primitive no longer runs. Derive the colour from the checked state and animate it yourself.

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, so its children parent to whatever frame surrounds it. Supply the container and layout yourself — which also lets a group’s items share a layout with neighbouring elements 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.