Facetguides

Variants and classes

fv() recipes, why cn does not merge conflicts, and the one ordering rule the whole model rests on.

@facet-ui/react-variants is Facet’s class-variance-authority equivalent, and the only runtime package a copied component imports from Facet. Two exports:

  • fv() — build a recipe: a base class string plus variant axes, returning a resolver from a selection to a flat class string.
  • cn() — flatten ClassValues into a class string.

Full signatures in the reference.

Recipes

import { fv, type VariantProps } from "@facet-ui/react-variants";
const buttonVariants = fv("flex-row items-center justify-center w-fit rounded-md", {
variants: {
variant: {
default: "bg-primary hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent",
},
size: {
sm: "h-8 px-3",
md: "h-9 px-4",
},
},
defaultVariants: { variant: "default", size: "md" },
});
type ButtonVariants = VariantProps<typeof buttonVariants>;
// → { variant?: "default" | "outline" | "ghost"; size?: "sm" | "md" }
buttonVariants({ variant: "outline", size: "sm", className: "w-full" });

Resolution order inside a recipe: base → each matching variant → matching compound variants → the caller’s className. The caller lands last, on purpose — which is the whole story of the next section.

Recipes hold no colour, size or font value of their own — they name tokens, which Vela resolves from your vela.config.ts at compile time. That is what makes a copied component themeable without editing it.

Compound variants

const badgeVariants = fv("rounded-full", {
variants: {
variant: { default: "bg-primary", outline: "border border-input" },
size: { sm: "px-2 py-0.5", md: "px-3 py-1" },
},
compoundVariants: [{ variant: "outline", size: "sm", className: "px-1.5" }],
defaultVariants: { variant: "default", size: "md" },
});

Matched against the resolved selection, defaults included. A compound fires whether the axis came from the caller or from defaultVariants.

cn does not resolve conflicts

shadcn’s cn is clsx + twMerge. Facet’s is only the flattener, and the reason twMerge exists does not survive the port.

On the web, class order in the attribute means nothing. Tailwind emits utilities in a canonical stylesheet order and the later rule wins, so p-4 p-2 and p-2 p-4 are the same. tailwind-merge exists to make attribute order mean what everyone assumes it does.

In Vela, tokens resolve left to right into instance properties and a later one overwrites an earlier one, so class order is specificity. fv() appends the caller’s className last, which makes the behaviour twMerge simulates native.

“The caller” here is whatever code calls the recipe — the component itself. A className passed in from outside is a different story. See Overriding from the call site.

The cross-family cases agree too:

px-4 p-2 → p-2 wins on both axes (twMerge drops px-4; same result)
p-2 px-4 → x from px-4, y from p-2 (twMerge keeps both; same result)
w-fit w-9 → w-9 (same family, later wins)

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 one rule

State-derived classes go through the recipe’s className slot, ahead of the consumer’s:

// wrong — the consumer cannot override the disabled look
cn(buttonVariants({ variant, size, className: props.className }), disabled && "bg-muted")
// right
buttonVariants({ variant, size, className: cn(disabled && "bg-muted", props.className) })

button had this backwards until it was written down. The failure is quiet: a consumer passes className="bg-red-500", nothing happens, and there is nothing in either file explaining why.

The same rule is why TextSlot takes no className — see Text and labels.

When to revisit this

If Vela ever stops resolving last-token-wins, this breaks silently: a consumer override that simply does nothing. Worth re-reading whenever Vela’s resolution order changes.

Overriding from the call site

Two Vela behaviors compose into it, and neither is wrong on its own:

1. className on a component is consumed at the call site. Vela lowers it where it is written, so the component never receives a string:

<Label className="text-muted-foreground" Text="1,200 coins" />
// emitted:
<Label Text="1,200 coins" TextColor3={Color3.fromRGB(161, 161, 170)} />

props.className inside Label is undefined, so the recipe slot never sees it. The same mechanism makes TextSlot take no className — load-bearing there, in the way here.

2. A component’s own class-derived props are emitted after its spreads. Whatever the author wrote, Vela moves the resolution to the end of the prop list:

// as authored in label.tsx
<textlabel className={cn(labelVariants({ className: props.className }))} {...passthrough} />
// as emitted
<VelaRuntimeHost {...passthrough} className={…} __velaTag={"textlabel"} />

So the TextColor3 from step 1 arrives through passthrough and is then overwritten by the component’s own recipe. Both halves of the override lose.

What that means for a copied component

The answer is the model’s answer: edit the file. That is what a copied component is for, and the only override that reliably works. variant and size cover the cases the component anticipated. Anything else is a change to source you own.

What still crosses the boundary is any instance property the component’s classes do not set — LayoutOrder, Position, Visible, ZIndex, event handlers. Those arrive through passthrough and survive, because nothing overwrites them.

That includes the properties a call-site className resolved to, which is what makes this confusing rather than merely inconvenient. The class list is not dropped as a unit. It is dropped property by property.

<ScrollArea className="h-32 rounded-md border border-border" />

rounded-md and border-border land — the root recipe names no corner and no stroke. h-32 does not: the recipe says h-full, and it resolves last. Two thirds of the class list works, which reads as “the class worked” right up until the size is wrong.

ClassValue is a reserved name

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 of 'ClassValue'.

~/lib/utils re-exports it as ClassName for this reason. Use that:

import { type ClassName, cn } from "~/lib/utils";

Dynamic classes are a different code path

Every Facet class string comes out of fv(), which makes it a computed expression — and Vela resolves computed className at runtime, not at compile time. For a long time that runtime path implemented a strict subset of the static lowering, and the missing families did nothing at all rather than erroring:

Vela versionWhat the computed path gained
0.7.0flex-*, items-*, justify-*, fit/auto, text-<size>, text-<align>, font-<weight>
0.8.0opacity-*, whitespace-*, leading-* — the last holdouts
0.9.0no new families; scoped the inlined runtime so card compiles at all

That history is why the first published button shipped zero pixels wide and every label sat on Roblox’s 8px default. No component carries a workaround for a missing family any more, and the CLI’s ^0.9.0 floor keeps you above the whole list.