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, built by rbxtsc. Everything else a component needs is 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 an override wins by construction, which is why cn need not merge conflicts and why nothing may be appended after props.className. See the one rule.

Compound variants match 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 string: strings pass through, numbers stringify, arrays recurse, records contribute keys whose value is true, and everything else 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. 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. 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.