Facetcomponents

Checkbox

The first component with a Lattice primitive underneath, and the first to mirror the state it styles by.

Terminal window
npx facet-rbxts add checkbox

Copies ui/checkbox.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants, @lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-checkbox@^0.8.0.

import { Checkbox } from "../shared/ui/checkbox";
<Checkbox defaultChecked onCheckedChange={(checked) => print(checked)} />
Unchecked, checked, indeterminate, disabled. The mark is a text glyph, not an image.

Renders a TextButton. Unknown props forward onto it and are type-checked against it, so a prop TextButton does not accept is a compile error.

Props

PropTypeDescription
checkedboolean | "indeterminate"Controlled value. Pass it with onCheckedChange to drive the box from your own state.
defaultCheckedboolean | "indeterminate"Uncontrolled starting value. Defaults to false.
onCheckedChange(checked: boolean | "indeterminate") => voidFires on every change, controlled or not.
disabledbooleanBlocks the press and adds opacity-50 to the recipe's className slot.
classNameClassNameThreaded into the 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.

No Text and no children — the box draws a glyph and nothing else. A label beside it is a separate Label in a flex-row frame, the pairing shadcn writes.

The state is mirrored, not reached for

This is the first thing every component in this tier had to solve, and the reason is one line of Lattice’s design:

const [checked, setChecked] = useControllableState<CheckedState>({
value: props.checked,
defaultValue: props.defaultChecked ?? false,
onChange: props.onCheckedChange,
});

Lattice keeps its contexts private: Checkbox.Root knows whether it is checked and nothing outside can read it. But the border and fill are this file’s job, so the wrapper needs the same answer.

The way out is not to reach into the primitive. Hold the value here with useControllableState, the same hook the primitive uses, and drive the primitive controlled from it. One copy of the state, in the file you own.

State classes go inside the slot

const className = checkboxVariants.root({
className: cn(
checked !== false && "border-primary bg-primary",
disabled && "opacity-50",
props.className,
),
});

Note the order: the state classes come before props.className, inside the recipe’s slot. Resolution is last-token-wins, so anything appended after the consumer’s class is an override they cannot undo — the one rule the whole registry follows.

checked !== false rather than checked === true: indeterminate is checked-enough to paint.

Three parts, one recipe object

export const checkboxVariants = {
root: fv("size-4 rounded-sm border border-input transition duration-150"),
indicator: fv("size-full flex-row items-center justify-center"),
glyph: fv("size-fit text-xs font-bold text-primary-foreground text-center"),
};

font-bold on the glyph is load-bearing, and not for weight. Vela leaves FontFace alone when no font-* appears, and Roblox’s default is LegacyArial. That is the bug that shipped in card. The glyph is its own textlabel, and nothing inherits.

size-fit on a glyph inside a size-full indicator is what centres it. The indicator does the items-center justify-center, and the glyph is only as big as the character.

The mark is a text glyph

<textlabel Text={checked === "indeterminate" ? "–" : "✓"} … />

Roblox has no icon font, so ✓ and – are characters. That is a settled position rather than a shortcut. Shipping images means owning the upload, moderation and licensing forever. Swap the textlabel for an imagelabel and nothing else changes.

Indeterminate is a value, not a flag

CheckedState is boolean | "indeterminate", so the third state travels through the same prop. A parent checkbox over a list of children is the case it exists for:

<Checkbox
checked={allChecked ? true : someChecked ? "indeterminate" : false}
onCheckedChange={(next) => setAll(next === true)}
/>