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. |
There is 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 same 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; nothing outside
the primitive can read that. But the border and the fill are this file’s job — border-input when
clear, border-primary bg-primary when not — so the wrapper needs the same answer.
The way out is not to reach into the primitive. It is to hold the value here with
useControllableState — the same hook the primitive uses — and then drive the primitive
controlled from it. One copy of the state, and it lives in the file you own.
switch, tabs, toggle-group, accordion and radio-group all do exactly this. Where a
component styles by a state, the state is mirrored. Where it does not — progress maps a number to
a width, radio-group’s inner dot is mounted and unmounted by the primitive — 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 and cn does not merge conflicts, so anything appended after the
consumer’s class would be an override the consumer cannot undo. That is 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-* token appears, and Roblox’s untouched default is LegacyArial — which is
the bug that shipped in card. The
glyph is its own textlabel and nothing inherits, so it states its own typeface like every other
text instance in the registry.
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, the moderation and the licensing
forever. The file is yours: swap the textlabel for an imagelabel with your own asset and nothing
else in the component changes.
Indeterminate is a value, not a flag
CheckedState is boolean | "indeterminate", so the third state travels through the same prop as
the other two. 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)}/>