npx facet-rbxts add radio-groupCopies ui/radio-group.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants,
@lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-radio-group@^0.8.0.
import { RadioGroup, RadioGroupItem } from "../shared/ui/radio-group";import { Label } from "../shared/ui/label";
<RadioGroup defaultValue="normal" onValueChange={setDifficulty}> <frame className="flex-row items-center gap-2 w-fit h-fit"> <RadioGroupItem value="normal" /> <Label Text="Normal" /> </frame></RadioGroup>import React from "@rbxts/react";import { Label } from "../ui/label";import { RadioGroup, RadioGroupItem } from "../ui/radio-group";import { MODE } from "../facet-mode";
/** * The item is only the dial — Facet ships no `RadioGroupLabel`, so the row is * a frame the consumer writes, the same way shadcn pairs an item with a * `<Label>`. */export function Difficulty() { return ( <RadioGroup defaultValue="normal"> <frame className="flex-row items-center gap-2 w-fit h-fit"> <RadioGroupItem value="easy" /> <Label Text="Casual" /> </frame> <frame className="flex-row items-center gap-2 w-fit h-fit"> <RadioGroupItem value="normal" /> <Label Text="Normal" /> </frame> <frame className="flex-row items-center gap-2 w-fit h-fit"> <RadioGroupItem value="hard" /> <Label Text="Hardcore" /> </frame> </RadioGroup> );}import { fv } from "@facet-ui/react-variants";import { type RadioGroupOrientation, RadioGroup as RadioGroupPrimitive } from "@lattice-ui/react-radio-group";import { React, useControllableState } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * The selected value is mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and an * item's border changes with whether it is the checked one. The dot inside * needs no mirror at all: `RadioGroup.Indicator` mounts and unmounts it from * the primitive's own state. * * One recipe object rather than four exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const radioGroupVariants = { // `gap-3` is shadcn's. Its root is a single-column `grid`, which Vela lowers // to a `UIGridLayout` with uniform cells — a column of radios is not uniform, // so `flex-col` is the shape and the gap is the part that carries over. root: fv("flex-col gap-3 w-fit h-fit"), item: fv("shrink-0 size-4 rounded-full border border-input shadow-sm transition duration-150"), indicator: fv("size-full flex-row items-center justify-center"), dot: fv("size-2 rounded-full bg-primary"),};
const RadioGroupContext = React.createContext<{ value?: string; disabled: boolean } | undefined>(undefined);
export type RadioGroupProps = { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; disabled?: boolean; orientation?: RadioGroupOrientation; className?: ClassName; children?: React.ReactNode;};
export type RadioGroupItemProps = { value: string; disabled?: boolean; className?: ClassName;};
export function RadioGroup(props: RadioGroupProps) { const [value, setValue] = useControllableState<string | undefined>({ value: props.value, defaultValue: props.defaultValue, onChange: props.onValueChange as (value: string | undefined) => void, });
const disabled = props.disabled === true; const contextValue = React.useMemo(() => ({ value, disabled }), [disabled, value]);
return ( <RadioGroupPrimitive.Root disabled={disabled} onValueChange={setValue} orientation={props.orientation} value={value} > <RadioGroupContext.Provider value={contextValue}> <frame className={radioGroupVariants.root({ className: cn(props.orientation === "horizontal" && "flex-row", props.className), })} BackgroundTransparency={1} > {props.children} </frame> </RadioGroupContext.Provider> </RadioGroupPrimitive.Root> );}
export function RadioGroupItem(props: RadioGroupItemProps) { const group = React.useContext(RadioGroupContext); if (group === undefined) { error("[RadioGroupItem] must be rendered inside a RadioGroup."); }
const checked = group.value === props.value; const disabled = group.disabled || props.disabled === true;
return ( <RadioGroupPrimitive.Item className={radioGroupVariants.item({ className: cn(checked && "border-primary", disabled && "opacity-50", props.className), })} disabled={props.disabled} value={props.value} > <RadioGroupPrimitive.Indicator className={cn(radioGroupVariants.indicator())}> <frame className={cn(radioGroupVariants.dot())} BorderSizePixel={0} /> </RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Item> );}The parts
| Part | Renders | Classes |
|---|---|---|
RadioGroup | Frame | flex-col gap-2 w-fit h-fit |
RadioGroupItem | TextButton | size-4 rounded-full border border-input transition duration-150 |
Two parts, not four. The indicator and the dot inside it are drawn by RadioGroupItem and take no
props. Exporting them would cost two Luau registers and buy nothing. See
the register limit.
Props
RadioGroup
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled value. Pass it with onValueChange. |
| defaultValue | string | Uncontrolled starting value. |
| onValueChange | (value: string) => void | Fires when a different item is checked. |
| disabled | boolean | Disables every item in the group. An item can also disable itself. |
| orientation | "horizontal" | "vertical" | Goes to the primitive for keyboard and gamepad navigation, and adds flex-row to the frame when horizontal. Defaults to vertical. |
| className | ClassName | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
RadioGroupItem
| Prop | Type | Description |
|---|---|---|
| value | string | Required. What this item sets the group to. |
| disabled | boolean | Disables this item alone. |
| className | ClassName | Threaded into the item recipe's className slot inside the component. |
There is no Text on either part — a radio button is a dial, and the words beside it are a
Label you place yourself.
Half the state is mirrored, half is not
The mirrored-state rule applies to what this file styles by, and here that is only the border:
const checked = group.value === props.value;
<RadioGroupPrimitive.Item className={radioGroupVariants.item({ className: cn(checked && "border-primary", disabled && "opacity-50", props.className), })}The dot inside needs no mirror: RadioGroup.Indicator mounts and unmounts its children from the
primitive’s own state, so there is no class to flip.
A Facet context carries the group’s value down to each item, because Lattice’s own is private. It exists for exactly one boolean per item.
orientation does two different things
<frame className={radioGroupVariants.root({ className: cn(props.orientation === "horizontal" && "flex-row", props.className), })}It goes to the primitive for arrow-key navigation and is read again here to swap the frame’s layout — two mechanisms sharing a prop. Nothing in Lattice lays this frame out, and nothing in the frame tells Lattice which key moves where.
The flex-row lands on the group’s own frame, so its children are laid out in a row. If each child is itself a labelled row, such as a flex-row frame holding a dial and a Label, you get a row of rows. That is usually what you want. The component only flips its own axis.