@facet-ui/react-variantsStable directionimport fv, cnThe 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" });| Parameter | Type | |
|---|---|---|
base | ClassValue | Classes every selection gets |
config.variants | Record<string, Record<string, ClassValue>> | Axis → option → classes |
config.defaultVariants | VariantSelection | Applied 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.
Unlike tailwind-merge, which of p-2 p-4 wins is Vela’s call, not this package’s — Vela resolves
left to right and a later token overwrites an earlier one, so class order is specificity. A merge
pass would cost a token table tracking every Vela family, kept in sync with a compiler still adding
families, to reproduce an outcome that is already correct.
The full argument.
Types
| Type | |
|---|---|
ClassValue | string | number | boolean | null | undefined | ClassDictionary | ClassValue[] |
ClassDictionary | Record<string, boolean | null | undefined> |
ClassItem | ClassValue minus null and undefined — what may sit inside an array this package builds |
VariantShape, VariantSelection, VariantConfig, CompoundVariant | Recipe 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.
Vela inlines its runtime into every file it transforms, and that runtime declares a local
ClassValue. A component importing that name gets TS2440: Import declaration conflicts with local declaration. ~/lib/utils re-exports it as ClassName for this reason.
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.