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 — they take
no props, so 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; the words next to it are a
Label you place yourself, exactly as shadcn does.
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 at all. RadioGroup.Indicator mounts and unmounts its children from
the primitive’s own state, so the dot is either rendered or it is not — there is no class to flip
and nothing for this file to know.
A Facet context carries the group’s value down to each item, because Lattice’s own context is private. That context 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, which uses it for arrow-key navigation, and it is read again here to swap the frame’s layout. Those are two separate mechanisms that happen to share 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 a labelled row of its own — a flex-row frame holding a dial and a Label — you get a row of
rows, which is usually what you want. Nesting is yours to arrange; the component only flips its own
axis.