@lattice-ui/react-radio-groupStable directionimport RadioGroupdepends on runtime, focus, layer, motionRadio 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.
import { RadioGroup } from "@lattice-ui/react-radio-group";import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";
function RadioGroupExample() { const { theme } = useTheme(); const [value, setValue] = React.useState("mentions");
const options: Array<{ label: string; description: string; value: string }> = [ { label: "Everything", description: "All new messages and threads.", value: "all" }, { label: "Mentions only", description: "Direct messages and @mentions.", value: "mentions" }, { label: "Muted", description: "Nothing — check in when you want.", value: "none" }, ];
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[16])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[16])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[10])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(280, 40)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(280, 18)} Text="Notifications" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 22)} Size={UDim2.fromOffset(280, 16)} Text="Notify me about…" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
<RadioGroup.Root onValueChange={setValue} value={value}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} LayoutOrder={1} Size={UDim2.fromOffset(280, 0)} > <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[10])} /> {options.map((option, index) => ( <RadioGroup.Item asChild key={option.value} value={option.value}> <textbutton AutoButtonColor={false} BackgroundTransparency={1} LayoutOrder={index} Size={UDim2.fromOffset(280, 40)} Text="" > <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Position={UDim2.fromOffset(0, 3)} Size={UDim2.fromOffset(18, 18)} > <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={value === option.value ? theme.colors.accent : theme.colors.border} Thickness={1} /> {value === option.value ? ( <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={theme.colors.accent} BorderSizePixel={0} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(8, 8)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> ) : undefined} </frame> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Position={UDim2.fromOffset(30, 2)} Size={UDim2.fromOffset(250, 18)} Text={option.label} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(30, 22)} Size={UDim2.fromOffset(250, 16)} Text={option.description} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </textbutton> </RadioGroup.Item> ))} </frame> </RadioGroup.Root> </frame> );}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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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. |