Style v2 with tailwind-variants/lite

The numbered v2 token scales (text-1…9, rounded-1…6, shadow-1…6)
share a prefix with the color/utility namespaces, and tailwind-merge
(which tailwind-variants runs by default) cannot tell a numeric
text-3 from a color step, so it silently dropped the size whenever a
text-<color> was present. Switch the v2 kit to the merge-free
tailwind-variants/lite entrypoint (added in v3): upgrade
tailwind-variants to ^3.2.2, import tv/VariantProps from
tailwind-variants/lite in the typography primitives, and drop the
per-call twMerge:false.

The legacy v1 kit stays on tailwind-variants (merge intact). Document
the rationale in ui.md and add a packages/ui/src/v2 ESLint guard that
forbids tailwind-variants, tailwind-merge, and clsx.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-24 12:31:27 +02:00
parent 863e41c47f
commit b374bc5211
7 changed files with 48 additions and 27 deletions

View File

@@ -20,7 +20,7 @@ These rules are the **source of truth**. The legacy tree (`Atoms/`, `Molecules/`
|------|------------|
| Package | **`@probo/ui`** — v2 components under `src/v2`. Apps opt into v2 by importing the v2 theme (see [`v2-tokens.md`](v2-tokens.md)). |
| Styling | **Tailwind v4** with the Radix-scale tokens (`bg-sand-3`, `text-sand-12`, `rounded-3`, `text-4`, …). |
| Variants API | **`tailwind-variants`** only — `import { tv } from "tailwind-variants"`. |
| Variants API | **`tailwind-variants/lite`** only — `import { tv } from "tailwind-variants/lite"`. The `/lite` entrypoint ships **without `tailwind-merge`**, which is required: the numbered scales (`text-1…9`, `rounded-1…6`, `shadow-1…6`) collide with the color/utility namespaces and tailwind-merge would silently drop the scale class (e.g. `text-3` next to `text-sand-11`). The legacy v1 kit stays on `tailwind-variants` (with merge). |
| Class composition | **Do not use `clsx` or `tailwind-merge`.** All conditional styling goes through `tv` variants and slots. |
| Headless primitives | **Base UI** (`@base-ui/react`). We **style** these primitives; we do not re-implement their behavior. |
@@ -58,7 +58,7 @@ export function Dialog({ trigger, ref, children }: Props) {
```tsx
// Good — thin styling over Base UI; consumers use the lib's open/onOpenChange directly
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
const dialog = tv({
slots: {
@@ -111,7 +111,7 @@ For **compound / multi-slot** components, define `tv` in a **dedicated `variants
```tsx
// Bad — same file mixes tv() output with ad-hoc Tailwind / clsx on className
import { clsx } from "clsx";
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
const row = tv({ base: "flex items-center gap-2" });
export function Row({ children }: { children: ReactNode }) {
@@ -121,7 +121,7 @@ export function Row({ children }: { children: ReactNode }) {
```tsx
// Good — layout and look live in tv
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
const row = tv({
base: "flex items-center gap-2 rounded-3 border border-sand-6",
@@ -133,7 +133,7 @@ export function Row({ children }: { children: ReactNode }) {
```tsx
// Good — optional styling toggles use tv variants, not extra className strings
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
const row = tv({
base: "flex items-center gap-2",
@@ -398,7 +398,7 @@ Model regions with `tv` `slots` named after the layout:
```ts
// ImageCard/variants.ts
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
export const imageCard = tv({
slots: {

View File

@@ -68,6 +68,34 @@ export default defineConfig([
],
},
},
{
// The v2 kit styles with tailwind-variants/lite (no tailwind-merge): the
// numbered scales (text-1…9, rounded-1…6, shadow-1…6) collide with the
// color/utility namespaces and tailwind-merge would drop the scale class.
files: ["packages/ui/src/v2/**"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "tailwind-variants",
message:
"Import from tailwind-variants/lite (no tailwind-merge) in v2.",
},
{
name: "tailwind-merge",
message: "The v2 kit does not use tailwind-merge.",
},
{
name: "clsx",
message: "The v2 kit does not use clsx; style via tailwind-variants/lite.",
},
],
},
],
},
},
{
files: reactDirs,
ignores: ["packages/ui/tailwind.config.js"],

27
package-lock.json generated
View File

@@ -18989,29 +18989,22 @@
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"license": "MIT",
"dependencies": {
"tailwind-merge": "3.0.2"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": {
@@ -21497,7 +21490,7 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.1",
"tw-animate-css": "^1.3.0",
"zustand": "^5.0.4"

View File

@@ -42,7 +42,7 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.1",
"tw-animate-css": "^1.3.0",
"zustand": "^5.0.4"

View File

@@ -13,7 +13,7 @@
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants/lite";
import { text } from "./variants";

View File

@@ -13,7 +13,7 @@
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants/lite";
import { textSkeleton } from "./variants";

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants";
import { tv } from "tailwind-variants/lite";
// Numbered type scale (text-1 … text-9). Each utility carries its paired
// font-size, line-height, and letter-spacing from the v2 theme. Shared between