Facetreference

@facet-ui/react-variants

fv(), cn(), and the types — including the two roblox-ts quirks the implementation exists to work around.

@facet-ui/react-variantsStable directionimport fv, cn

The one runtime package a Facet component imports from Facet. An rbxts package, built by rbxtsc — everything else a component needs is either a Lattice primitive or a Vela class string.

Installed by facet add as a dependency of the utils registry item. Currently 0.4.0.

fv(base, config?)

Facet variants. The cva-shaped recipe builder.

import { fv, type VariantProps } from "@facet-ui/react-variants";
const buttonVariants = fv("flex-row items-center w-fit rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
},
size: { sm: "h-8 px-3", md: "h-9 px-4" },
},
compoundVariants: [{ variant: "outline", size: "sm", className: "px-2.5" }],
defaultVariants: { variant: "default", size: "md" },
});
buttonVariants({ variant: "outline", size: "sm", className: "w-full" });
ParameterType
baseClassValueClasses every selection gets
config.variantsRecord<string, Record<string, ClassValue>>Axis → option → classes
config.defaultVariantsVariantSelectionApplied when the caller omits an axis
config.compoundVariants(VariantSelection & { className: ClassValue })[]Extra classes when several axes match at once

Returns (selection?) => string. The selection is one optional key per axis, plus a className slot.

Resolution order: base → each matching variant → matching compound variants → the caller’s className. The caller lands last so a consumer override wins by construction — which is why cn does not need to merge conflicts, and why nothing in a component may be appended after props.className. See the one rule.

Compound variants match against the resolved selection, defaults included, so one fires whether the axis came from the caller or from defaultVariants.

VariantProps<Recipe>

type ButtonVariants = VariantProps<typeof buttonVariants>;
// → { variant?: "default" | "outline"; size?: "sm" | "md" }

Extracts a recipe’s selection type, so a component’s props can be declared from its recipe rather than restated beside it:

export type ButtonProps = VariantProps<typeof buttonVariants> & {
Text?: string;
disabled?: boolean;
};

cn(...inputs)

cn("h-9", disabled && "opacity-50", { "bg-muted": isMuted }, ["gap-2", "px-4"]);
// → "h-9 opacity-50 bg-muted gap-2 px-4"

Flattens ClassValues into a space-separated class string. Strings pass through, numbers are stringified, arrays recurse, records contribute their keys where the value is true, and everything else — booleans, nil — is dropped.

Types

Type
ClassValuestring | number | boolean | null | undefined | ClassDictionary | ClassValue[]
ClassDictionaryRecord<string, boolean | null | undefined>
ClassItemClassValue minus null and undefined — what may sit inside an array this package builds
VariantShape, VariantSelection, VariantConfig, CompoundVariantRecipe types

ClassValue mirrors Vela’s exactly, and exactness matters rather than similarity: Vela augments React.Attributes with className, and TypeScript intersects React.Attributes into every component’s props — so props.className inside a Facet component carries Vela’s type no matter what the component declared. A narrower mirror simply fails to accept it.

Two roblox-ts quirks in the implementation

Worth knowing if you edit this package or write something like it.

Lua tables cannot hold nil without leaving a hole. roblox-ts therefore rejects undefined and null as array element types — they are the same value at runtime. Accepting them on the way in is fine; they are just never stored, which is what ClassItem exists to express.

Arrays and records lower onto the same Lua table type, so the first key’s type is what tells them apart at runtime:

const firstKey = next(value as unknown as UnknownTable)[0];
if (typeIs(firstKey, "number")) { /* array */ } else { /* record */ }

There is also a type-level trap in fv’s signature: the Variants default is Record<never, never> rather than VariantShape, because mapping over VariantShape’s string keys produces an index signature of string | undefined, which then collides with the className slot on the selection object. Without that default, fv("…") with no variants fails to type-check at every call site.