@lattice-ui/react-toggle-groupStable directionimport ToggleGroupdepends on runtime, focus, motionToggle Group is the primitive for a related set of toggles that share selection state: view switches, filter chips, text-alignment pickers, and difficulty selectors. Root owns the selected value and tells each Item whether it is pressed, so your items only render their two visual states.
Reach for Toggle Group when several toggles belong together and should behave as one control — either a single-select group where picking one clears the others, or a multiple-select group where each item toggles independently. For a lone on/off control, Checkbox or Switch is the better fit.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";import { ToggleGroup } from "@lattice-ui/react-toggle-group";
function ToggleGroupExample() { const { theme } = useTheme(); const [formats, setFormats] = React.useState<Array<string>>(["bold"]); const [heading, setHeading] = React.useState("h1");
const formatItems: Array<{ label: string; value: string; font: Enum.Font }> = [ { label: "B", value: "bold", font: Enum.Font.GothamBold }, { label: "I", value: "italic", font: Enum.Font.Gotham }, { label: "U", value: "underline", font: Enum.Font.Gotham }, ];
const headingItems: Array<{ label: string; value: string }> = [ { label: "H1", value: "h1" }, { label: "H2", value: "h2" }, ];
const renderItem = (label: string, font: Enum.Font, pressed: boolean, layoutOrder: number) => ( <textbutton AutoButtonColor={false} BackgroundColor3={theme.colors.accent} BackgroundTransparency={pressed ? 0 : 1} BorderSizePixel={0} LayoutOrder={layoutOrder} Size={UDim2.fromOffset(34, 34)} Text="" > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <Text BackgroundTransparency={1} Font={font} Size={UDim2.fromScale(1, 1)} Text={label} TextColor3={pressed ? theme.colors.accentContrast : theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} /> </textbutton> );
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[6])} PaddingLeft={new UDim(0, theme.space[6])} PaddingRight={new UDim(0, theme.space[6])} PaddingTop={new UDim(0, theme.space[6])} /> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, theme.space[6])} VerticalAlignment={Enum.VerticalAlignment.Center} />
<ToggleGroup.Root asChild onValueChange={setFormats} type="multiple" value={formats}> <frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(106, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, theme.space[2])} /> {formatItems.map((item, index) => ( <ToggleGroup.Item asChild key={item.value} value={item.value}> {renderItem(item.label, item.font, formats.includes(item.value), index)} </ToggleGroup.Item> ))} </frame> </ToggleGroup.Root>
<frame BackgroundColor3={theme.colors.border} BorderSizePixel={0} LayoutOrder={1} Size={UDim2.fromOffset(1, 22)} />
<ToggleGroup.Root asChild onValueChange={(nextValue) => setHeading(nextValue ?? "")} type="single" value={heading} > <frame BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(70, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, theme.space[2])} /> {headingItems.map((item, index) => ( <ToggleGroup.Item asChild key={item.value} value={item.value}> {renderItem(item.label, Enum.Font.GothamMedium, heading === item.value, index)} </ToggleGroup.Item> ))} </frame> </ToggleGroup.Root> </frame> );}Import
import { ToggleGroup } from "@lattice-ui/react-toggle-group";Anatomy
A Root wrapping one Item per option. Root requires a type that fixes the whole group as single- or multiple-select.
ToggleGroup anatomy
<ToggleGroup.Root type="single"> <ToggleGroup.Item value="..." /> <ToggleGroup.Item value="..." /></ToggleGroup.Root>| Part | Required | Responsibility |
|---|---|---|
ToggleGroup.Root | yes | Owns the selected value(s), the select mode, and group-wide disabled state. |
ToggleGroup.Item | yes | One toggle; reads its pressed state from Root. |
Examples
Single-select toolbar
The smallest useful group: an uncontrolled single-select mode picker seeded with defaultValue. Each default item renders as a textbutton labeled with its value, which makes it easy to wire the group up before styling anything. In single mode, activating the already-pressed item clears the selection — onValueChange then fires with undefined — so a toolbar like this can also be fully deselected.
import { ToggleGroup } from "@lattice-ui/react-toggle-group";
export function HotbarModePicker() { return ( <ToggleGroup.Root type="single" defaultValue="build" onValueChange={(mode) => print(`active mode: ${mode}`)} > <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <ToggleGroup.Item value="build" /> <ToggleGroup.Item value="terrain" /> <ToggleGroup.Item value="wire" /> </ToggleGroup.Root> );}Multiple-select filters
Switch type to "multiple" and the value becomes a string[]: each item toggles independently, and the array preserves the order in which items were selected. defaultValue seeds the initial set for uncontrolled usage (it defaults to an empty array), and onValueChange receives the full next array on every toggle.
import { ToggleGroup } from "@lattice-ui/react-toggle-group";
export function ItemFilters() { return ( <ToggleGroup.Root type="multiple" defaultValue={["new"]} onValueChange={(filters) => print(`active filters: ${filters.join(", ")}`)} > <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <ToggleGroup.Item value="new" /> <ToggleGroup.Item value="owned" /> <ToggleGroup.Item value="tradable" /> </ToggleGroup.Root> );}Controlled group
Pass value and onValueChange when something outside the group reads or sets the selection. Here a start button reads the picked difficulty and stays inert while the selection is cleared — in single mode the controlled value is string | undefined, so design the surrounding UI around the undefined case.
import { useState } from "@rbxts/react";import { ToggleGroup } from "@lattice-ui/react-toggle-group";
export function DifficultyPicker(props: { onStart: (difficulty: string) => void }) { const [difficulty, setDifficulty] = useState<string | undefined>("normal");
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(320, 90)}> <uilistlayout Padding={new UDim(0, 10)} SortOrder={Enum.SortOrder.LayoutOrder} />
<ToggleGroup.Root type="single" value={difficulty} onValueChange={setDifficulty}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <ToggleGroup.Item value="casual" /> <ToggleGroup.Item value="normal" /> <ToggleGroup.Item value="hardcore" /> </ToggleGroup.Root>
<textbutton Active={difficulty !== undefined} AutoButtonColor={difficulty !== undefined} BackgroundColor3={ difficulty !== undefined ? Color3.fromRGB(88, 142, 255) : Color3.fromRGB(59, 66, 84) } Event={{ Activated: () => { if (difficulty !== undefined) { props.onStart(difficulty); } }, }} Size={UDim2.fromOffset(160, 32)} Text={difficulty !== undefined ? `Start (${difficulty})` : "Pick a difficulty"} TextColor3={Color3.fromRGB(240, 244, 252)} /> </frame> );}Disabled group and disabled items
disabled works at two levels. On Root it freezes the whole control: every item ignores activation, and Root’s toggle logic is short-circuited so nothing can change the value while it is set. On an Item it disables just that option while the rest of the group keeps working. Here the loadout is locked during a match, and one class is additionally locked behind premium.
import { ToggleGroup } from "@lattice-ui/react-toggle-group";
export function LoadoutModes(props: { inMatch: boolean; hasPremium: boolean }) { return ( <ToggleGroup.Root type="single" defaultValue="assault" disabled={props.inMatch}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <ToggleGroup.Item value="assault" /> <ToggleGroup.Item value="recon" /> <ToggleGroup.Item value="specialist" disabled={!props.hasPremium} /> </ToggleGroup.Root> );}A disabled item is marked inactive (Active={false}) but still renders its pressed state, so a locked-in selection stays visibly selected.
Custom pressed visuals with asChild
Use asChild on Root when you need a different container class, and on each Item to project the toggle behavior onto your own button. Since 0.7.0 the primitive never writes an item’s colors, so read the pressed state from Root and set BackgroundColor3 and TextColor3 yourself (see How it behaves).
import { useState } from "@rbxts/react";import { ToggleGroup } from "@lattice-ui/react-toggle-group";
const VIEWS = ["grid", "list", "detail"];
export function StyledViewSwitch() { const [view, setView] = useState<string | undefined>("grid");
return ( <ToggleGroup.Root type="single" value={view} onValueChange={setView} asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(248, 44)} > <uicorner CornerRadius={new UDim(0, 8)} /> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} HorizontalAlignment={Enum.HorizontalAlignment.Center} Padding={new UDim(0, 6)} VerticalAlignment={Enum.VerticalAlignment.Center} />
{VIEWS.map((viewName) => ( <ToggleGroup.Item value={viewName} key={viewName} asChild> <textbutton AutoButtonColor={false} Size={UDim2.fromOffset(76, 32)} Text={viewName} TextSize={14}> <uicorner CornerRadius={new UDim(0, 6)} /> <uistroke Color={Color3.fromRGB(88, 142, 255)} Thickness={view === viewName ? 1 : 0} /> </textbutton> </ToggleGroup.Item> ))} </frame> </ToggleGroup.Root> );}ToggleGroup.Item no longer accepts a transition as of 0.7.0 — the prop existed only to time the color animation that has been removed. Animate your own colors if you want the change to ease.
How it behaves
Value state
ToggleGroup.Root is controllable. Pass value and onValueChange to control the selection, or omit them and pass defaultValue to let the group own it — onValueChange still fires on every change in uncontrolled usage. Each Item reads its own pressed state from the group through context, so controlled and uncontrolled usage behave identically. Activating an item routes through Root, which updates the shared value.
Single vs multiple
The required type prop fixes the group’s behavior and the shape of its value:
type="single"—value/defaultValuearestring, andonValueChangereceivesstring | undefined. Selecting an item replaces the current value; selecting the already-selected item clears it, so the value becomesundefined.type="multiple"—value/defaultValuearestring[], andonValueChangereceivesstring[]. Each item toggles independently: a newly selected value is appended to the end, so the array preserves selection order. Incoming values (controlledvalue,defaultValue, and the array handed toonValueChange) are normalized — duplicate and non-string entries are stripped.
Because the value type and the onValueChange signature are a discriminated union over type, set type as a literal so TypeScript can narrow to the right props.
Activation and selection
ToggleGroup.Item toggles on Activated and on Return/Space via InputBegan, so mouse, touch, keyboard, and gamepad activation all work. It renders an unstyled textbutton with AutoButtonColor off, and does not render value as its text — pass a Text of your own. Items are rendered with Selectable={false} on the button itself — manage gamepad selection at the surrounding layout if you need it.
With asChild, the toggle behavior is merged onto your single child element through the shared Slot: the slot’s Active, Selectable, and motion ref win over the child’s own props, and event handlers compose (both the primitive’s Activated/InputBegan handlers and any you pass on the child run). Use a textbutton or imagebutton so Activated fires.
Group container
Root renders a transparent, borderless frame with no size of its own — it is a context boundary, not a laid-out surface. Put a uilistlayout (or your own positioning) inside it to arrange the items, and pass a Size so it reserves space in a flow layout.
Disabled state
disabled on Root disables the entire group; disabled on an Item disables just that one. A disabled item ignores both activation and keyboard toggles and is marked inactive (Active={false}), but keeps rendering its pressed visuals. Group-level disabled additionally short-circuits Root’s toggle logic, so no value change can get through while it is set — including from an item that missed the flag.
Item colors
An item’s BackgroundColor3 and TextColor3 are entirely yours as of 0.7.0. Each item used to animate between fixed active and inactive palettes with no way to opt out; the primitive now writes neither, in either mode. Read the pressed state from ToggleGroup.Root and branch on it, adding your own response motion if you want the change to ease rather than snap.
Activating the pressed item in a single-select group deselects it; there is no built-in “always keep one selected” mode. If your UI requires a selection, run controlled and ignore the clear: onValueChange={(next) => next !== undefined && setValue(next)}.
The Root frame is transparent and unsized, so it reserves no space in a uilistlayout or uigridlayout parent. Pass a Size — or asChild with your own container — whenever the group sits in a flow layout.
Before 0.7.0 ToggleGroup.Item animated fixed active/inactive palettes that could not be overridden, and took a transition to time them. Both are gone — the item writes no colors and accepts no transition. Colors you set stay exactly as you set them.
API reference
ToggleGroup.Root
Root takes the common props below plus the single- or multiple-mode props selected by type.
| Prop | Type | Description |
|---|---|---|
| type | "single" | "multiple" | Required. Fixes the group as single- or multiple-select and determines the value shape. Pass a literal so TypeScript narrows the union. |
| value | string | string[] | Controlled selection. string in single mode, string[] (normalized: duplicates and non-strings stripped) in multiple mode. Pair with onValueChange. |
| defaultValue | string | string[] | Initial selection for uncontrolled usage. string in single mode; string[] (default []) in multiple mode. |
| onValueChange | (value: string | undefined) => void | (value: string[]) => void | Called on every selection change, controlled or not. Receives string | undefined in single mode and the full next string[] in multiple mode. |
| disabled | boolean | Disables every item and short-circuits the group's toggle logic so no value change gets through. Defaults to false. |
| asChild | boolean | Render the single child element as the group container instead of the frame the part renders. |
| children | React.ReactNode | The toggle items and any layout. Must be a single valid element when asChild is set. |
ToggleGroup.Item
| Prop | Type | Description |
|---|---|---|
| value | string | Required. Identifies this item within the group. Since 0.7.0 it is not rendered as the button's text — pass Text yourself. |
| disabled | boolean | Disables just this item — activation and keyboard toggles are ignored and the button is marked inactive. Defaults to false. |
| asChild | boolean | Merge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| children | React.ReactNode | Rendered inside the item button, or the element to project onto. Must be a single element when asChild is set. |