npx facet-rbxts add toggle-groupCopies ui/toggle-group.tsx, plus lib/utils.ts and lib/text.tsx. Needs
@facet-ui/react-variants, @lattice-ui/react-runtime@^0.8.0 and
@lattice-ui/react-toggle-group@^0.8.0.
import { ToggleGroup, ToggleGroupItem } from "../shared/ui/toggle-group";
<ToggleGroup type="single" defaultValue="grid" onValueChange={setView}> <ToggleGroupItem value="list" Text="List" /> <ToggleGroupItem value="grid" Text="Grid" /></ToggleGroup>import React from "@rbxts/react";import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";import { MODE } from "../facet-mode";
/** * Single on top, multiple below. The pressed surface is `bg-accent` and the * label flips to `text-accent-foreground` — two classes on two different * instances, because nothing inherits. */export function ToggleGroups() { return ( <frame className="flex-col gap-3 w-full h-fit"> <ToggleGroup type="single" defaultValue="grid"> <ToggleGroupItem value="list" Text="List" /> <ToggleGroupItem value="grid" Text="Grid" /> <ToggleGroupItem value="map" Text="Map" /> </ToggleGroup> <ToggleGroup type="multiple" defaultValue={["bold", "italic"]}> <ToggleGroupItem value="bold" Text="Bold" /> <ToggleGroupItem value="italic" Text="Italic" /> <ToggleGroupItem value="under" Text="Underline" /> </ToggleGroup> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps, useControllableState,} from "@lattice-ui/react-runtime";import { ToggleGroup as ToggleGroupPrimitive } from "@lattice-ui/react-toggle-group";import { TextSlot } from "~/lib/text";import { type ClassName, cn } from "~/lib/utils";
/** * The group's value is mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and each * item's surface changes with whether it is pressed. The primitive is then * driven controlled, so there is exactly one copy of the state, and a Facet * context hands each item its answer. * * One recipe object rather than three exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const toggleGroupVariants = { root: fv("flex-row items-center gap-1 w-fit h-fit"), item: fv( "flex-row items-center justify-center gap-2 h-9 w-fit min-w-9 px-2 rounded-md transition duration-150 hover:bg-muted", ), label: fv("whitespace-nowrap text-sm font-medium text-foreground"),};
type ToggleGroupContextValue = { isPressed: (value: string) => boolean; disabled: boolean;};
const ToggleGroupContext = React.createContext<ToggleGroupContextValue | undefined>(undefined);
export type ToggleGroupProps = { /** `single` keeps at most one item pressed; `multiple` lets them accumulate. */ type: "single" | "multiple"; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[] | undefined) => void; disabled?: boolean; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type ToggleGroupItemProps = { value: string; disabled?: boolean; Text?: string; className?: ClassName; children?: React.ReactNode;};
const ROOT_OWN_PROPS = ["type", "value", "defaultValue", "onValueChange", "disabled", "className", "children"] as const;
export function ToggleGroup(props: ToggleGroupProps) { const [value, setValue] = useControllableState<string | string[] | undefined>({ value: props.value, defaultValue: props.defaultValue, onChange: props.onValueChange, });
const disabled = props.disabled === true;
const isPressed = React.useCallback( (itemValue: string) => { if (typeIs(value, "table")) { return (value as string[]).includes(itemValue); } return value === itemValue; }, [value], );
const contextValue = React.useMemo(() => ({ isPressed, disabled }), [disabled, isPressed]);
// `className` is written out as an attribute on both branches rather than // tucked into a shared spread: Vela rewrites the call site it can *see*, and // a className hidden inside a spread would reach the primitive as a raw prop // instead of being resolved. // // The primitive's props are a discriminated union over `type`, and the // mirrored value is the union's width — so one branch per arm keeps the // narrowing honest instead of casting across it. const className = toggleGroupVariants.root({ className: props.className }); const passthrough = toSlotProps(getPassthroughProps<Frame>(props, ROOT_OWN_PROPS));
return ( <ToggleGroupContext.Provider value={contextValue}> {props.type === "multiple" ? ( <ToggleGroupPrimitive.Root className={className} type="multiple" value={typeIs(value, "table") ? (value as string[]) : []} onValueChange={setValue} disabled={disabled} {...passthrough} > {props.children} </ToggleGroupPrimitive.Root> ) : ( <ToggleGroupPrimitive.Root className={className} type="single" value={typeIs(value, "string") ? value : undefined} onValueChange={setValue} disabled={disabled} {...passthrough} > {props.children} </ToggleGroupPrimitive.Root> )} </ToggleGroupContext.Provider> );}
export function ToggleGroupItem(props: ToggleGroupItemProps) { const group = React.useContext(ToggleGroupContext); if (group === undefined) { error("[ToggleGroupItem] must be rendered inside a ToggleGroup."); }
const pressed = group.isPressed(props.value); const disabled = group.disabled || props.disabled === true;
return ( <ToggleGroupPrimitive.Item className={toggleGroupVariants.item({ className: cn(pressed && "bg-accent hover:bg-accent", disabled && "opacity-50", props.className), })} disabled={props.disabled} value={props.value} > <TextSlot Text={props.Text} TextTransparency={disabled ? 0.5 : 0} className={cn(toggleGroupVariants.label(), pressed && "text-accent-foreground")} > {props.children} </TextSlot> </ToggleGroupPrimitive.Item> );}The parts
| Part | Renders | Classes |
|---|---|---|
ToggleGroup | Frame | flex-row items-center gap-1 w-fit h-fit |
ToggleGroupItem | TextButton | flex-row items-center justify-center h-9 w-fit px-3 rounded-md transition duration-150 hover:bg-muted |
Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error.
That is the root. ToggleGroupItem takes no passthrough bag — it renders a button and a label, and
there is no single instance an unknown prop should land on.
Props
ToggleGroup
| Prop | Type | Description |
|---|---|---|
| type | "single" | "multiple" | Required. single keeps at most one item pressed; multiple lets them accumulate. This is what decides whether value is a string or an array. |
| value | string | string[] | Controlled value. A string under single, an array under multiple. |
| defaultValue | string | string[] | Uncontrolled starting value. |
| onValueChange | (value: string | string[] | undefined) => void | Fires on every change. Under single it can fire with undefined — pressing the pressed item clears the selection. |
| disabled | boolean | Disables every item in the group. |
| 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. |
ToggleGroupItem
| Prop | Type | Description |
|---|---|---|
| value | string | Required. What this item contributes. |
| disabled | boolean | Disables this item alone. |
| Text | string | The label. Drawn by TextSlot as a child TextLabel, not by the button. |
| children | React.ReactNode | An alternative to Text — an icon, a row, whatever the item should contain. |
| className | ClassName | Threaded into the item recipe's className slot inside the component. |
className is written out on both branches
This is the component that turned a Vela rule into a convention. The root is rendered twice, once
per arm of type:
const className = toggleGroupVariants.root({ className: props.className });
{props.type === "multiple" ? ( <ToggleGroupPrimitive.Root className={className} type="multiple" … {...passthrough} />) : ( <ToggleGroupPrimitive.Root className={className} type="single" … {...passthrough} />)}The obvious tidy-up — folding className into the shared passthrough bag — is the thing that
breaks it.
A className written as an attribute is a call site the transformer resolves. A className
tucked inside a spread is just a key in an object; it reaches the primitive as a raw string prop and
is dropped in silence, with no diagnostic. The component renders unstyled and nothing says why.
Write it as an attribute. Every component in the registry does, including the ones where a spread would read better.
Two branches rather than a cast, because the primitive’s props are a discriminated union over type
and the mirrored value is the union’s full width. One branch per arm keeps the narrowing honest.
The pressed state, and where it lands
The mirrored state is the
group’s value, held with useControllableState and handed to each item through a Facet context —
Lattice’s own is private, and each item’s surface changes with whether it is pressed.
className={toggleGroupVariants.item({ className: cn(pressed && "bg-accent hover:bg-accent", disabled && "opacity-50", props.className),})}hover:bg-accent is repeated on the pressed branch on purpose. The base recipe has
hover:bg-muted, and last-token-wins means the later hover:bg-accent replaces it — without it, a
pressed item would go lighter under the cursor than it is at rest.
The label’s colour is a second class on a second instance:
<TextSlot className={cn(toggleGroupVariants.label(), pressed && "text-accent-foreground")}>Nothing inherits, so the surface and the text state their halves separately — the same shape as
Alert and
Text field.
toggle is not in the registry
There is no standalone Toggle, and it is not an oversight: @lattice-ui/react-toggle does not
exist. A single pressed button is exactly the controlled/uncontrolled state logic that belongs in
Lattice rather than in a file you copy, so it waits for the primitive.
A one-item group covers it today:
<ToggleGroup type="single"> <ToggleGroupItem value="muted" Text="Mute" /></ToggleGroup>