@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()— flattenClassValues 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
This is the entire price of not having a merge pass, and it is a rule you inherit the moment you edit a copied component.
State-derived classes go through the recipe’s className slot, ahead of the consumer’s:
// wrong — the consumer cannot override the disabled lookcn(buttonVariants({ variant, size, className: props.className }), disabled && "bg-muted")
// rightbuttonVariants({ 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
Measured, not predicted: a <Button className="bg-destructive" Text="Override" /> rendered in the docs’ own preview gallery comes out bg-primary, exactly like a <Button /> with no className. The class is dropped in silence, with no diagnostic on either side.
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 version | What the computed path gained |
|---|---|
| 0.7.0 | flex-*, items-*, justify-*, fit/auto, text-<size>, text-<align>, font-<weight> |
| 0.8.0 | opacity-*, whitespace-*, leading-* — the last holdouts |
| 0.9.0 | no 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.
The rule that outlives the specific gaps. Check what the emitted runtime actually resolves:
grep -o 'startsWith(token, "[a-z0-9-]*")' out/shared/ui/button.luau | sort -uWhatever is missing there is missing at runtime, whatever the static path or the docs say.