React 整合:Vite 裝 Tailwind v4 + cn/cva | TailwindCSS 完整教學
React 整合 是把 TailwindCSS 用得優雅的分水嶺。在 Vite + React 專案裡,TailwindCSS v4 只要一個
@tailwindcss/viteplugin 就能裝好;而真正讓元件庫可維護的關鍵,是三件工具:cn()合併 class 並解決衝突、cva打造類型安全的變體系統、tailwind-merge讓外部className能正確覆寫預設。這一篇帶你走完安裝、封裝可重用元件、設定 IntelliSense,並拆解動態 class 拼接這個最致命的坑,讓你在 React 裡把 Tailwind 寫得又乾淨又穩。
前言
React 是當今最主流的前端函式庫,而 TailwindCSS 幾乎是它的預設搭檔。上一篇《Next.js 整合》我們談的是「有框架外殼」的情境;這一篇把外殼剝掉一層,回到最純粹的 Vite + React 專案——沒有 Server Components、沒有 App Router,就是元件、props、與 className。所謂「React 整合」,講的不只是把 Tailwind 裝進 Vite,更重要的是:如何把 utility class 收進可重用元件裡,而不讓 className 變成一團難以維護的字串。
先用一個生活化的類比。把 Tailwind 的 utility class 想成一盒樂高積木——每一塊都小、都標準、都能自由組合。單獨玩幾塊很簡單,但當你要蓋一整座城堡(一套設計系統),散落一地的積木就會變成災難:同一個按鈕在十個檔案裡寫了十種略有出入的 class,改一個顏色要改十個地方。這時你需要的是說明書和分裝盒:cva 就是那本說明書(定義每種按鈕長什麼樣、有哪些尺寸),cn() 是那個能把積木正確拼合、還會自動丟掉互相打架積木的智慧夾具,而 tailwind-merge 則保證當你想「換掉某一塊」時(外部傳入 className),新的積木真的能蓋過舊的,而不是兩塊卡在一起誰也不讓誰。
這套工具鏈解決的,是 Tailwind 最為人詬病的兩個痛點:一是「元件裡的 class 字串又臭又長、難以複用」,二是「想覆寫預設樣式時,class 衝突導致樣式失效」。搞懂 cn() 與 cva,你就能把 Tailwind 從「到處貼 class」升級成「一套有型別、可覆寫、可維護的元件庫」——這正是 shadcn/ui 這類現代元件庫的底層設計。
本系列以 TailwindCSS v4 為預設版本。本篇你將學到:
- v4 安裝:用
@tailwindcss/vite一個 plugin 把 Tailwind 裝進 Vite + React,以及為什麼它比 PostCSS 路徑更省事 cn()工具:用clsx+tailwind-merge封裝出一個既能條件挑 class、又能解決衝突的合併函式cva變體系統:用 Class Variance Authority 定義類型安全的元件變體(variant / size),並自動推導 props 型別- 可重用元件與
className合併:讓元件既有預設樣式,又允許呼叫端透過classNameprop 正確覆寫 - IntelliSense 與陷阱:設定 VS Code 讓
cn、cva內也能補全,以及動態 class 拼接為什麼會讓樣式憑空消失
本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。
核心概念
v4 + Vite 的整合:一個 plugin 就夠
先看清楚資料怎麼流。在 Vite + React 專案裡,一段 Tailwind class 從你寫下到變成瀏覽器裡的樣式,走的是這條路:你在 .tsx 裡寫了 className="bg-white p-4" → Vite 的建置流程觸發 @tailwindcss/vite 這個 plugin → Tailwind 的 Oxide 引擎掃描專案原始碼、只生成用到的 utility → 產出最終 CSS 注入頁面。
這裡的關鍵是:v4 為 Vite 環境準備了專屬的 @tailwindcss/vite plugin,官方明確推薦在純 Vite 專案(涵蓋 React、Vue、Svelte、SolidJS)使用它。它整合最緊密、設定最少——不需要 postcss.config.js、不需要手動配置 content globs,效能也優於走 PostCSS 的路徑。這和上一篇的分界要牢記:純 Vite → @tailwindcss/vite,Next.js → @tailwindcss/postcss。兩者用途不同,別把 Next.js 的 plugin 抄到 Vite 專案、也別反過來。只有當你的 Vite 專案已有既有的 PostCSS pipeline 非用不可,才退而使用 @tailwindcss/postcss。
至於 CSS 入口,v4 一律收斂成單行 @import "tailwindcss",取代 v3 那三行 @tailwind base/components/utilities 指令。整個安裝的心智模型很單純:把 plugin 掛進 Vite、把那一行 @import 寫進 CSS,剩下的掃描與生成 Oxide 引擎會自動接手。
順帶一提,你可能還記得早年 React 專案多半用 Create React App(CRA) 起手。在 Tailwind 的脈絡下,CRA 因為底層是 webpack、又缺乏官方維護,整合體驗遠不如 Vite——設定繁瑣、HMR 慢、生態支援也逐漸淡出。v4 官方的推薦路徑已經明確倒向 Vite,因此本篇一律以 Vite 示範。如果你手上還有 CRA 老專案要接 Tailwind,長遠來看更值得投資的是把建置工具遷移到 Vite,而不是為 CRA 硬湊一套整合。下面這張表把兩者的差異攤開,幫你判斷:
| 項目 | Create React App | Vite(本篇採用) |
|---|---|---|
| 底層 bundler | webpack | Rollup / esbuild |
| dev 啟動速度 | 慢(需完整打包) | 極快(原生 ESM) |
| HMR 熱更新 | 較慢 | 近乎即時 |
| Tailwind v4 整合 | 需走 PostCSS、設定繁瑣 | 官方 @tailwindcss/vite,一個 plugin |
| 官方維護狀態 | 已淡出 | 活躍,社群主流 |
這張表的結論很直接:新專案一律選 Vite,它和 Tailwind v4 的整合是官方第一等公民,設定最少、效能最好。
cn():合併 class 的智慧夾具
React 元件寫 Tailwind,很快會遇到一個問題:class 是有條件的(依 props 切換),而且元件常需要允許外部覆寫(透過 className prop)。這兩件事若用原始字串拼接處理,程式碼會又亂又容易出 bug。cn() 就是為此而生的工具,它由兩個函式組合而成:
clsx:負責「條件挑選」。它接受字串、物件、陣列,自動過濾掉 falsy 值,讓你能用{ "bg-cyan-600": isActive }這種物件語法優雅地依條件開關 class。tailwind-merge(twMerge):負責「解決衝突」。Tailwind 的 class 若兩個互斥(如px-4和px-8),單純並排時瀏覽器是按 CSS 生成順序決定勝負,而不是「後寫的贏」,這常導致覆寫失效。twMerge認得 Tailwind 的 class 群組,會自動移除前面被覆蓋的那個,確保後者正確勝出。
把兩者串起來就是 cn():先用 clsx 把條件攤平成一串 class,再交給 twMerge 去重與解衝突。所以 cn = twMerge(clsx(...)) 這個等式,是理解整套工具鏈的核心。有了它,你才能安心地在元件末尾放一個 className 參數,讓呼叫端傳進來的 class 真的能覆蓋元件預設值,而不是「兩個 padding 卡在一起」。
這裡值得澄清一個常見誤解:clsx 和 tailwind-merge 各做各的、缺一不可,不是二選一。如果你只用 clsx,能依條件挑 class,但兩個互斥的 utility(例如外部想覆寫的 bg-red-600 和元件預設的 bg-cyan-600)會雙雙留在字串裡,誰勝出全看 CSS 生成順序,覆寫常常失效。反過來,如果你只用 tailwind-merge,能解衝突,卻少了物件語法那種「依 boolean 條件開關 class」的便利。把兩者組成 cn(),才同時拿到「條件挑選」加「衝突解決」這兩件事——這也是為什麼幾乎所有現代 Tailwind 元件庫都採用這個組合,而不是單用其中一個。名字叫 cn 只是慣例(class names 的縮寫),你要叫它別的名字也行,重點是那個 twMerge(clsx()) 的組合。
cva:類型安全的元件變體系統
cn() 解決的是「合併」,但當一個元件有多個維度的變體(按鈕有 primary / outline / ghost 等外觀,又有 sm / md / lg 等尺寸),光靠 cn() 手動堆條件會越寫越長。這時就輪到 Class Variance Authority(CVA) 上場。
cva 讓你用宣告式的方式定義變體:給一段基礎樣式(所有變體共用),再在 variants 裡分別列出每個維度的每個選項對應的 class,還能設 defaultVariants(預設值)與 compoundVariants(特定組合的額外樣式)。呼叫 buttonVariants({ variant: "outline", size: "lg" }) 就會回傳組合好的 class 字串。
cva 最大的價值是類型安全:搭配它提供的 VariantProps<typeof buttonVariants> 泛型,你能從 cva 的定義自動推導出 TypeScript 型別,元件的 props 型別和 class 定義永遠同步——你新增一個 variant,型別就自動多一個選項,傳錯值 TypeScript 會直接報錯。這讓 Tailwind 補回了它相較 CSS-in-JS 唯一遜色的「動態變體」能力,而且做得更好:無 runtime、有型別。實務上,cva 產出基礎 class,再用 cn() 和外部 className 合併,就是現代 Tailwind 元件庫(如 shadcn/ui)的標準骨架。
這裡要釐清 cn() 和 cva 的分工,避免混用時搞混:cva 負責「產生」變體 class,cn() 負責「合併」多來源 class。你可以把 cva 想成一台發料機——你按下「outline + lg」的按鈕,它吐出對應那一組完整 class;而 cn() 是組裝台——把發料機吐出的 class,和外部傳進來的 className 疊在一起、去掉打架的,產出最終字串。兩者不是替代關係:單純的元件(只有一種樣式、沒有變體維度)用 cn() 就夠;一旦出現多維度變體(外觀 × 尺寸 × 狀態),就該引入 cva 把變體邏輯抽出來,再讓 cn() 收尾合併。搞清楚這條分工線,你的元件結構會非常清晰:cva 定義「這個元件有哪些長相」,cn() 處理「這次呼叫實際要長什麼樣」。
另外值得一提的是 compoundVariants(複合變體)這個常被忽略的功能。有時某些樣式只在特定組合下才需要——例如「outline 外觀」搭配「lg 尺寸」時邊框要加粗成 border-2,但其他尺寸的 outline 用一般邊框就好。這種「條件是多個變體的交集」的情境,若硬塞進單一 variant 定義會很彆扭,compoundVariants 正是為此設計:你列出觸發條件(variant: "outline", size: "lg")和該套用的額外 class,cva 會在命中組合時自動疊加。這讓你能精細地表達設計系統裡那些「例外規則」,而不必為每個組合各開一個新變體。
實作範例
理論說完,實際把 Tailwind v4 裝進一個 Vite + React 專案,並一步步封裝出 cn() 工具與 cva 按鈕元件。
範例一:安裝——@tailwindcss/vite 一個 plugin 到位
先建立專案並安裝依賴。這裡用官方腳手架建立一個 React + TypeScript 專案,再裝上 Tailwind v4 的 Vite plugin:
# 建立 Vite + React + TypeScript 專案
npm create vite@latest my-app -- --template react-ts
cd my-app
# 安裝 TailwindCSS v4 與其 Vite plugin
npm install tailwindcss @tailwindcss/vite
接著把 plugin 掛進 vite.config.ts,和 react() 並列即可。注意這裡不需要 postcss.config.js:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
react(),
tailwindcss(), // Tailwind v4 Vite plugin,免 postcss.config.js、免 content 設定
],
});
然後在 CSS 入口寫上那一行 @import,這是 v4 唯一必要的一行:
/* src/index.css — v4 單行入口,取代 v3 的三行 @tailwind 指令 */
@import "tailwindcss";
最後確認 main.tsx 有把這支 CSS 載進來——漏了這一步,樣式完全不會出現:
// src/main.tsx
import "./index.css"; // 載入 Tailwind,漏掉則所有樣式失效
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
範例二:封裝 cn() 工具
cn() 是後續所有元件的地基,先把它裝好。安裝 clsx 與 tailwind-merge:
npm install clsx tailwind-merge
接著建立一支工具檔。cn() 的定義就是這麼精簡——twMerge 包住 clsx:
// src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
// cn() = 先用 clsx 依條件挑 class,再用 twMerge 解決 Tailwind 衝突
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
它的威力在於同時處理「條件挑選」與「衝突解決」。看 twMerge 怎麼讓後者正確勝出:
import { twMerge } from "tailwind-merge";
// 沒有 twMerge 時,兩個 p-* 都留著,靠 CSS 順序決定,常導致覆寫失效
// 有 twMerge:自動移除被覆蓋的 utility
twMerge("p-4", "p-2"); // → "p-2"(後者勝出)
twMerge("px-4 py-2", "p-8"); // → "p-8"(p-8 覆蓋 px-4 py-2)
twMerge("text-red-500", "text-blue-500"); // → "text-blue-500"
範例三:用 cva 打造類型安全的按鈕變體
有了 cn(),現在用 cva 定義一個完整的按鈕元件。先安裝:
npm install class-variance-authority
用 cva 宣告變體:基礎樣式共用,variant 與 size 各自列出選項,並設定預設值。再用 VariantProps 自動推導 props 型別:
// src/components/ui/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// 用 cva 定義變體:第一參數是基礎樣式,第二參數是各維度變體
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
primary: "bg-cyan-600 text-white hover:bg-cyan-700 focus-visible:ring-cyan-500",
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50",
ghost: "text-gray-700 hover:bg-gray-100",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
// 特定組合的額外樣式
compoundVariants: [{ variant: "outline", size: "lg", class: "border-2" }],
// 預設值:沒傳 variant/size 時套用
defaultVariants: { variant: "primary", size: "md" },
}
);
// VariantProps 從 cva 定義自動推導出型別,props 與 class 永遠同步
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
// cva 產出基礎 class,再用 cn() 和外部 className 合併(允許覆寫)
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
使用時,型別會完整補全與檢查——傳錯 variant 會直接紅字報錯:
// 各種變體組合,型別安全
<Button variant="primary" size="lg">送出</Button>
<Button variant="outline" size="sm">取消</Button>
<Button variant="ghost">略過</Button>
// 外部傳 className 覆寫預設:twMerge 讓 bg-red-600 正確蓋過 bg-cyan-600
<Button className="bg-red-600 hover:bg-red-700">刪除</Button>
範例四:className 合併——讓元件既有預設又可覆寫
可重用元件的黃金原則是:元件提供合理預設,但永遠開一個 className 缺口讓呼叫端微調。關鍵就在把外部 className 放在 cn() 的最後一個參數,交給 twMerge 處理衝突:
// src/components/ui/Input.tsx
import { cn } from "@/lib/utils";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
}
export function Input({ error, className, ...props }: InputProps) {
return (
<input
className={cn(
"w-full rounded-lg border px-3 py-2 outline-none transition-colors",
"focus:ring-2 focus:ring-cyan-500 focus:border-transparent",
// 用物件語法依條件切換(每個都是完整可掃描的 class)
error ? "border-red-500 bg-red-50" : "border-gray-300",
className // 放最後,讓外部 class 能正確覆寫預設
)}
{...props}
/>
);
}
// 使用:外部想改圓角,直接傳 className,twMerge 讓 rounded-full 蓋過 rounded-lg
// <Input className="rounded-full" placeholder="搜尋..." />
範例五:設定 VS Code IntelliSense 認得 cn / cva
預設情況下,Tailwind CSS IntelliSense(VS Code 官方擴充)只在 class / className 屬性裡提供自動補全與 hover 提示。一旦你把 class 寫進 cn()、cva() 這類函式呼叫,補全就失效了。解法是在專案的 VS Code 設定裡告訴擴充「這些函式裡也是 class」:
// .vscode/settings.json
{
"tailwindCSS.classFunctions": ["cn", "cva", "clsx", "tv"],
"tailwindCSS.classAttributes": ["class", "className", "classList"]
}
搭配 prettier-plugin-tailwindcss,還能讓 cn、cva 內的 class 依官方順序自動排序——在 Prettier 設定裡把這些函式名登記到 tailwindFunctions,格式化時就會把它們當成 class 容器來排序:
// prettier.config.js — 讓 class 排序也涵蓋 cn / cva
export default {
plugins: ["prettier-plugin-tailwindcss"],
tailwindFunctions: ["cn", "cva", "clsx", "tv"],
};
這樣一來,無論 class 寫在 className 還是 cn() / cva() 裡,你都能享有補全、hover 預覽與自動排序,把「寫進工具函式就沒補全」的痛點一次補齊。一個小提醒:classFunctions 是給 VS Code 擴充(補全與 hover)看的,tailwindFunctions 是給 Prettier plugin(排序)看的,兩者是不同工具的設定、名字也不同,建議兩邊都設好,開發體驗才完整。
常見錯誤與最佳實踐
坑一:動態 class 拼接——樣式憑空消失
這是 React 專案裡最高頻、最致命的陷阱。Tailwind 靠靜態掃描原始碼決定生成哪些 utility,它不會執行你的 JavaScript。所以任何用變數「組」出來的 class 中間片段,它都掃不到:
// ❌ 危險:Tailwind 掃不到 bg-cyan-500,原始碼裡只有 bg-、-500 碎片,樣式消失
function Tag({ color }: { color: string }) {
return <div className={`bg-${color}-500 text-${color}-900`} />;
}
問題的根源是:原始碼裡從頭到尾不存在 bg-cyan-500 這個完整字串。正確做法是建一個「完整 class 字串」的對照表,讓每個候選 class 都以完整、可被掃描的形式存在:
// ✅ 安全:用完整 class 名稱的 lookup table,每個字串都可被靜態掃描
const colorMap = {
cyan: { bg: "bg-cyan-500", text: "text-cyan-900" },
red: { bg: "bg-red-500", text: "text-red-900" },
green: { bg: "bg-green-500", text: "text-green-900" },
} as const;
type ColorKey = keyof typeof colorMap;
function Tag({ color }: { color: ColorKey }) {
const { bg, text } = colorMap[color];
return <div className={cn(bg, text)} />;
}
鐵律:永遠不要用變數去拼接 class 名稱的中間片段。 要動態,就用「完整字串的對照表」或 cva / cn() 的物件語法,讓每一個可能用到的 class 都完整地出現在原始碼裡。
坑二:className 覆寫衝突——沒有 twMerge 就失效
很多人以為「把外部 className 直接串在字串後面」就能覆寫,結果發現改不動:
// ❌ 只用字串串接:兩個 bg-* 都存在,靠 CSS 順序決定,覆寫常失效
function Button({ className }: { className?: string }) {
return <button className={`bg-cyan-600 px-4 py-2 ${className ?? ""}`} />;
}
// <Button className="bg-red-600" /> → bg-cyan-600 可能仍生效,紅色沒出來
原因是 Tailwind 的兩個 bg-* class 特異性相同,誰勝出取決於它們在最終 CSS 裡的生成順序,而不是你在 className 裡的先後。解法就是用 cn()——twMerge 認得這是互斥的 bg-*,會移除前者:
// ✅ 用 cn():twMerge 移除被覆蓋的 bg-cyan-600,bg-red-600 正確勝出
import { cn } from "@/lib/utils";
function Button({ className }: { className?: string }) {
return (
<button className={cn("bg-cyan-600 px-4 py-2", className)} />
);
}
坑三:自訂 utility 被 twMerge 誤刪
若你在 v4 的 @theme 裡定義了自訂 utility(例如 text-brand-sm),tailwind-merge 預設不認得它,去重時可能誤判、誤刪你的自訂 class。解法是用 extendTailwindMerge 向它註冊你的 class 群組:
// src/lib/utils.ts
import { extendTailwindMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx";
// 告訴 tailwind-merge:這些自訂 text-* 是互斥的同一群組
const customTwMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [{ text: ["brand-sm", "brand-md", "brand-lg"] }],
},
},
});
export function cn(...inputs: ClassValue[]) {
return customTwMerge(clsx(inputs));
}
v4 相容性提醒:
tailwind-mergev3.x 對應支援 Tailwind v4。若沒有自訂 utility,基本的twMerge(clsx())就夠用;只有在@theme加了自訂 token 時,才需要上面的extendTailwindMerge註冊,否則去重時它可能誤刪你的自訂 class。
最佳實踐小結
- 安裝走 Vite plugin:純 React + Vite 專案用
@tailwindcss/vite,一個 plugin 到位,別誤用 Next.js 的@tailwindcss/postcss。 cn()是地基:記牢cn = twMerge(clsx()),先裝好它,後面所有元件都靠它合併與解衝突。- 變體交給
cva:多維度變體(variant / size)用cva宣告,搭配VariantProps拿到型別安全,props 與 class 永遠同步。 className永遠放最後:元件開一個className缺口、放在cn()最末,讓呼叫端能正確覆寫預設。- 設定 IntelliSense:在
.vscode/settings.json用tailwindCSS.classFunctions登記cn、cva,讓工具函式裡也有補全。 - 絕不動態拼接 class:用完整字串的對照表或物件語法,讓每個 class 都完整出現在原始碼裡,避免樣式憑空消失。
小結
這是 TailwindCSS 完整教學 系列的第四十篇。上一篇《Next.js 整合》,我們把 Tailwind v4 接進 App Router,靠 @tailwindcss/postcss 這個轉接頭走 PostCSS 管線,並釐清 Server / Client Component 下 class 用法一致;這一篇,我們把框架外殼剝掉,回到純 Vite + React,聚焦在如何把 utility class 收進可維護的元件庫。回顧幾個重點:
- v4 + Vite 安裝:用專屬的
@tailwindcss/viteplugin,一個 plugin 到位,免postcss.config.js、免 content 設定;CSS 入口收斂成單行@import "tailwindcss"。 cn()工具:cn = twMerge(clsx())——clsx負責條件挑選、twMerge負責解決 Tailwind class 衝突,是所有元件合併 class 的地基。cva變體系統:用宣告式定義 variant / size,搭配VariantProps自動推導型別,補回 Tailwind 相較 CSS-in-JS 唯一遜色的動態變體能力,而且無 runtime、有型別。- 可重用元件:
cva產出基礎 class、cn()與外部className合併,把className放最後讓外部可覆寫,就是 shadcn/ui 這類元件庫的標準骨架。 - 三大坑:動態 class 拼接(用對照表)、
className覆寫衝突(用cn())、自訂 utility 誤刪(用extendTailwindMerge),對照就能穩定避開。
掌握了 cn() 與 cva,你就有了在任何 React 專案裡打造專業元件庫的完整基礎。下一篇《Vue 整合》,我們換一個生態系——看 TailwindCSS 如何搭配 Vue + Vite,以及在 Vue 的 <template> 與 SFC 結構下,綁定動態 class、封裝可重用元件會有哪些不同的手法與眉角。