npx facet-rbxts add checkboxCopies 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)} />import React from "@rbxts/react";import { Checkbox } from "../ui/checkbox";import { Label } from "../ui/label";import { MODE } from "../facet-mode";
/** * The four states the recipe distinguishes. Only the first is uncontrolled and * clickable in the ordinary sense — the rest are seeded so the preview shows * what each looks like without anyone having to reach them. */export function Checkboxes() { return ( <frame className="flex-col gap-3 w-full h-fit"> <frame className="flex-row items-center gap-2 w-full h-fit"> <Checkbox /> <Label Text="Remember me" /> </frame> <frame className="flex-row items-center gap-2 w-full h-fit"> <Checkbox defaultChecked /> <Label Text="Checked" /> </frame> <frame className="flex-row items-center gap-2 w-full h-fit"> <Checkbox defaultChecked="indeterminate" /> <Label Text="Indeterminate" /> </frame> <frame className="flex-row items-center gap-2 w-full h-fit"> <Checkbox defaultChecked disabled /> <Label Text="Disabled" /> </frame> </frame> );}import { fv } from "@facet-ui/react-variants";import { Checkbox as CheckboxPrimitive, type CheckedState } from "@lattice-ui/react-checkbox";import { getPassthroughProps, type PassthroughProps, React, toSlotProps, useControllableState,} from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * The checked state is mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and the * box's border and fill change with it. The primitive is then driven controlled, * so there is exactly one copy of the state and it lives in this file. * * 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 checkboxVariants = { // `rounded-sm` is shadcn's `rounded-[4px]` by another name — both are 4px. root: fv("shrink-0 size-4 rounded-sm border border-input shadow-sm transition duration-150"), indicator: fv("size-full flex-row items-center justify-center"), // The glyph is a text instance of its own, and nothing inherits: without a // `font-*` it would render in LegacyArial, not in the theme's typeface. glyph: fv("size-fit text-xs font-bold text-primary-foreground text-center"),};
export type CheckboxProps = { checked?: CheckedState; defaultChecked?: CheckedState; onCheckedChange?: (checked: CheckedState) => void; disabled?: boolean; className?: ClassName;} & PassthroughProps<TextButton>;
const OWN_PROPS = ["checked", "defaultChecked", "onCheckedChange", "disabled", "className"] as const;
export function Checkbox(props: CheckboxProps) { const [checked, setChecked] = useControllableState<CheckedState>({ value: props.checked, defaultValue: props.defaultChecked ?? false, onChange: props.onCheckedChange, });
const disabled = props.disabled === true;
// State classes sit inside the recipe's className slot, ahead of the // consumer's: resolution is last-token-wins, so anything appended after // `props.className` would be an override the consumer cannot undo. const className = checkboxVariants.root({ className: cn(checked !== false && "border-primary bg-primary", disabled && "opacity-50", props.className), });
return ( <CheckboxPrimitive.Root checked={checked} className={className} disabled={disabled} onCheckedChange={setChecked} {...toSlotProps(getPassthroughProps<TextButton>(props, OWN_PROPS))} > <CheckboxPrimitive.Indicator className={cn(checkboxVariants.indicator())}> {/* An icon font does not exist on Roblox, so the mark is a text glyph — replace this label to use your own artwork. */} <textlabel className={cn(checkboxVariants.glyph())} Text={checked === "indeterminate" ? "–" : "✓"} BackgroundTransparency={1} BorderSizePixel={0} /> </CheckboxPrimitive.Indicator> </CheckboxPrimitive.Root> );}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
| Prop | Type | Description |
|---|---|---|
| checked | boolean | "indeterminate" | Controlled value. Pass it with onCheckedChange to drive the box from your own state. |
| defaultChecked | boolean | "indeterminate" | Uncontrolled starting value. Defaults to false. |
| onCheckedChange | (checked: boolean | "indeterminate") => void | Fires on every change, controlled or not. |
| disabled | boolean | Blocks the press and adds opacity-50 to the recipe's className slot. |
| className | ClassName | Threaded 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.
switch, tabs, toggle-group, accordion and radio-group all do this. Where a component styles by a state, the state is mirrored. Where it does not, there is no mirror, because nothing here needed to know.
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)}/>