Facetcomponents

Toggle group

A set of two-state buttons — and the component that had to write its `className` out twice to keep the compiler able to see it.

Terminal window
npx facet-rbxts add toggle-group

Copies 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>
Single on top, multiple below. A pressed item takes bg-accent and its label takes text-accent-foreground.

The parts

PartRendersClasses
ToggleGroupFrameflex-row items-center gap-1 w-fit h-fit
ToggleGroupItemTextButtonflex-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

PropTypeDescription
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.
valuestring | string[]Controlled value. A string under single, an array under multiple.
defaultValuestring | string[]Uncontrolled starting value.
onValueChange(value: string | string[] | undefined) => voidFires on every change. Under single it can fire with undefined — pressing the pressed item clears the selection.
disabledbooleanDisables every item in the group.
classNameClassNameThreaded 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

PropTypeDescription
valuestringRequired. What this item contributes.
disabledbooleanDisables this item alone.
TextstringThe label. Drawn by TextSlot as a child TextLabel, not by the button.
childrenReact.ReactNodeAn alternative to Text — an icon, a row, whatever the item should contain.
classNameClassNameThreaded 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.

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>