# @facet-ui/react-variants

> fv(), cn(), and the types — including the two roblox-ts quirks the implementation exists to work around.

Source: https://docs.astra-void.xyz/facet/reference/variants/

`@facet-ui/react-variants` · Stable direction · import `fv, cn`

The 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.

```tsx
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](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#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>`

```tsx
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:

```tsx
export type ButtonProps = VariantProps<typeof buttonVariants> & {
  Text?: string;
  disabled?: boolean;
};
```

## `cn(...inputs)`

```tsx
cn("h-9", disabled && "opacity-50", { "bg-muted": isMuted }, ["gap-2", "px-4"]);
// → "h-9 opacity-50 bg-muted gap-2 px-4"
```

Flattens `ClassValue`s 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.

> **cn does not resolve conflicting utilities**
>
> 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](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#cn-does-not-resolve-conflicts).

## 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.

> **Import it as ClassName, not ClassValue**
>
> 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:

```tsx
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.
