chore: migrate from clsx + tailwind-merge to shadcn-ui/cn (#49938)

Migrates the repo off `clsx` + `tailwind-merge` to
[shadcn-ui/cn](https://github.com/shadcn-ui/cn). Every app and package
already gets `cn` from `packages/ui`, so the swap happens in that one
helper and flows through to Studio, docs, www, and the rest.

**Changed:**
- `packages/ui` `cn` helper now uses `createCn` from `cn/config`,
keeping the custom `card`/`content` spacing scale so `p-card` still
overrides `p-4`. It has an explicit signature and re-exports
`ClassValue`.
- The four www Launch Week files that imported the `ClassValue` type
from `clsx` now import it from `ui`.
- `blocks/vue` local `lib/utils.ts` re-exports `cn` from the package.
- Comments/README that referenced tailwind-merge.

**Removed:**
- Direct `clsx` and `tailwind-merge` deps from `ui`, `ui-patterns`,
`www`, and `blocks/vue`. `ui-patterns` and `www` declared them without
importing.

**Added:**
- `packages/ui/src/lib/utils/cn.test.ts` covering clsx-style joining,
conflict resolution, the custom spacing scale, and variant handling.

Not migrated: the standalone apps under `examples/`. They're outside the
workspace and mostly on Tailwind v3, which `cn` doesn't support.

Lockfile note: after merging master, the lockfile diff is only the
intended swap (`clsx` and `tailwind-merge` out, `cn@0.2.5` in).
`tailwind-merge` stays in the lockfile as a transitive dep of a
third-party package.

Release-age note: this sat in draft with a temporary
`minimumReleaseAgeExclude` entry for `cn` while `cn` was inside the
workspace's 3-day `minimumReleaseAge` window. That window has closed, so
the exclusion is gone and nothing bypasses the release-age gate.

## To test

- `pnpm install --frozen-lockfile` succeeds with no
`minimumReleaseAgeExclude` entry for `cn`.
- `pnpm --filter ui test` – new `cn.test.ts` passes, including
`cn('p-4', 'p-card')` → `p-card`.
- Typecheck passes for studio, ui, ui-patterns, vue-blocks. www
typecheck panics under tsgo on master already (pre-existing, unrelated);
it passes with the JS `tsc` binary.
- Spot-check Studio locally: class overrides still win in the usual
places (e.g. `CodeEditor` height, `Button` variants with a custom
`className`).

https://claude.ai/code/session_01MkAt16tsPRDTm9oB5Jr8Ub


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Standardized Tailwind class merging across shared UI utilities while
preserving conditional classes, custom spacing classes, and variant
behavior.
* Updated related components and examples to use the standardized
class-merging utility.

* **Tests**
* Added coverage for conditional class handling, conflicting utility
resolution, custom spacing classes, and variant separation.

* **Documentation**
* Updated usage guidance to reflect the standardized Tailwind
class-merging approach.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-09-07 21:35:06 +08:00
1 parent b0601f9ab7
commit ec1029dff0
15 files changed
+54 -45

No files matched your search

@@ -9,7 +9,7 @@ import { render } from '@/tests/helpers'
* win — otherwise the editor collapses to a single line.
*
* This regressed once already: #47339 appended the default as `cn(className, 'monaco-editor',
* 'h-full')`, and tailwind-merge keeps the *last* conflicting height utility, so the trailing
* 'h-full')`, and `cn` keeps the *last* conflicting height utility, so the trailing
* `h-full` clobbered caller heights. #47350 fixed it by passing `className` last. These tests
* lock that ordering in.
*
@@ -260,7 +260,7 @@ export const CodeEditor = ({
// injects a second copy of Monaco's CSS onto the shared instance). Without an explicit
// height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px.
// Order matters: `h-full` is a default, so a caller-supplied height in `className`
// (e.g. `h-96`) wins via tailwind-merge instead of being clobbered.
// (e.g. `h-96`) wins via `cn` class merging instead of being clobbered.
className={cn('monaco-editor', 'h-full', className)}
wrapperProps={{ className: wrapperClassName }}
value={value ?? undefined}
@@ -1,7 +1,7 @@
// see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference
import { type ClassValue } from 'clsx'
import { ReactNode } from 'react'
import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
@@ -1,8 +1,8 @@
// see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference
import { type ClassValue } from 'clsx'
import { BookCopy, Flag, GitBranch, HardDrive } from 'lucide-react'
import { ReactNode } from 'react'
import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
@@ -1,9 +1,9 @@
// see apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx for reference
import { type ClassValue } from 'clsx'
import { AppWindow, Database, Globe } from 'lucide-react'
import { ReactNode } from 'react'
import { PRODUCT_MODULES } from 'shared-data/products'
import type { ClassValue } from 'ui'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
@@ -1,5 +1,5 @@
import type { ClassValue } from 'clsx'
import type { ReactNode } from 'react'
import type { ClassValue } from 'ui'
export interface BuildDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
-1
View File
@@ -44,7 +44,6 @@
"animejs": "^4.0.2",
"class-variance-authority": "^0.7.1",
"classnames": "^2.3.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"common": "workspace:*",
"common-tags": "^1.8.2",
+1 -2
View File
@@ -15,11 +15,10 @@
"@supabase/supabase-js": "catalog:",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.2.4",
"h3": "^1.15.10",
"lucide-vue-next": "^0.562.0",
"nuxt": "^4.4.6",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.35",
"vue-router": "^4.5.1"
},
+1 -7
View File
@@ -1,7 +1 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export { cn } from 'cn'
-1
View File
@@ -774,7 +774,6 @@
"@supabase/supabase-js": "catalog:",
"@tanstack/react-table": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"common": "workspace:*",
"common-tags": "^1.8.2",
+1 -1
View File
@@ -16,7 +16,7 @@ Some of the components have the `_Shadcn_` suffix. These components should be pr
```tsx
// deep object merge (used for themes)
import { clipboard, cn, mergeDeep } from 'ui' // clsx + tailwind-merge
import { clipboard, cn, mergeDeep } from 'ui' // Tailwind class merging (shadcn-ui/cn)
// copy-to-clipboard helper
```
+1 -2
View File
@@ -18,7 +18,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.2.4",
"cmdk": "^1.1.1",
"color": "^4.2.3",
"date-fns": "^2.30.0",
@@ -38,7 +38,6 @@
"react-resizable-panels": "^4.6.5",
"recharts": "catalog:",
"sonner": "^1.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "catalog:",
"vaul": "^1.1.2"
},
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { cn } from './cn'
describe('cn', () => {
it('joins conditional inputs like clsx', () => {
expect(cn('a', ['b', { c: true, d: false }], undefined, null, false && 'e')).toBe('a b c')
})
it('resolves conflicting Tailwind utilities, last one wins', () => {
expect(cn('p-2 text-foreground', 'p-4', 'text-foreground-light')).toBe(
'p-4 text-foreground-light'
)
expect(cn('h-full', 'h-96')).toBe('h-96')
})
it('treats the custom card/content spacing scale as spacing utilities', () => {
expect(cn('p-4', 'p-card')).toBe('p-card')
expect(cn('px-content', 'px-6')).toBe('px-6')
expect(cn('gap-2', 'gap-card')).toBe('gap-card')
})
it('keeps variants separate from base utilities', () => {
expect(cn('p-2 hover:p-4', 'md:p-6')).toBe('p-2 hover:p-4 md:p-6')
})
})
+5 -7
View File
@@ -1,14 +1,12 @@
import { ClassValue, clsx } from 'clsx'
import { extendTailwindMerge } from 'tailwind-merge'
import type { ClassValue } from 'cn'
import { createCn } from 'cn/config'
const twMerge = extendTailwindMerge({
export type { ClassValue } from 'cn'
export const cn: (...inputs: ClassValue[]) => string = createCn({
extend: {
theme: {
spacing: ['card', 'content'],
},
},
})
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+13 -18
View File
@@ -1787,9 +1787,6 @@ importers:
classnames:
specifier: ^2.3.1
version: 2.3.2
clsx:
specifier: ^2.1.1
version: 2.1.1
cobe:
specifier: ^0.6.5
version: 0.6.5
@@ -2058,9 +2055,9 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
cn:
specifier: ^0.2.4
version: 0.2.5
h3:
specifier: 1.15.11
version: 1.15.11
@@ -2070,9 +2067,6 @@ importers:
nuxt:
specifier: ^4.4.6
version: 4.5.2(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7(supports-color@8.1.1)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.41)(aws4fetch@1.0.20)(cac@6.7.14)(commander@14.0.1)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(esbuild@0.28.1)(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.48.0)(tsx@4.22.4)(typescript@6.0.2)(vite@7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))(yaml@2.9.0)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
vue:
specifier: ^3.5.35
version: 3.5.35(typescript@6.0.2)
@@ -2619,12 +2613,12 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
cn:
specifier: ^0.2.4
version: 0.2.5
color:
specifier: ^4.2.3
version: 4.2.3
@@ -2679,9 +2673,6 @@ importers:
sonner:
specifier: ^1.5.0
version: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwindcss:
specifier: 'catalog:'
version: 4.2.4
@@ -2761,9 +2752,6 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -10388,6 +10376,11 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
cn@0.2.5:
resolution: {integrity: sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==}
engines: {node: '>=20'}
hasBin: true
cobe@0.6.5:
resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==}
@@ -27335,6 +27328,8 @@ snapshots:
- '@types/react'
- '@types/react-dom'
cn@0.2.5: {}
cobe@0.6.5:
dependencies:
phenomenon: 1.6.0