@facet-ui/react-variantsStable directionimport fv, cnThe 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" });| 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 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.
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. It would 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. 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. 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.