@lattice-ui/react-radio-groupStable directionimport RadioGroupdepends on runtime, focus, layer, motionRadio 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
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
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.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
Uncontrolled state seeded with defaultValue. With no container of its own, the group borrows the
layout of the 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 needs to read or set the choice. It fires
with the newly selected value and never with undefined.
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.
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.
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.
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.
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.
On a gamepad, moving selection onto an item selects it immediately, and arrow navigation focuses and selects in one step. That matches the native radio pattern, but means a group should not be the default-selected element unless you want the first hover to commit a choice.
Movement clamps to the first and last available items rather than wrapping, and steps over disabled or invisible ones as if they were not there.
The item never writes colour, and accepts no 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. |