shadcn/ui:複製貼上、你擁有原始碼的元件庫 | TailwindCSS 完整教學

2026/08/31
shadcn/ui:複製貼上、你擁有原始碼的元件庫 | TailwindCSS 完整教學

上一篇《無樣式元件庫》我們認識了 Radix + TailwindCSS 這個「一個管行為、一個管樣式」的黃金組合——但每次都要自己逐一安裝原語、拼裝子元件、寫一堆 data-* class,實在繁瑣。這一篇的主角 shadcn/ui 就是解法:它把「Radix + Tailwind + cva + cn()」整套打包成可複製貼上、你完全擁有原始碼的元件。它不是你 npm install 的套件,而是用 CLI 把元件複製進你自己的 repo,從此想怎麼改都行。

前言

shadcn/ui 是近年 React + TailwindCSS 生態裡最紅、也最「反直覺」的一套 UI 方案。它顛覆了我們對「元件庫」的既定想像:它不是一個你安裝的 npm 套件,而是一個「複製貼上(copy-paste)」的元件集合。你透過它的命令列工具(CLI),把某個元件的原始碼直接複製進你自己的專案目錄——從那一刻起,那份程式碼就百分之百屬於你,你可以像改自己寫的元件一樣自由編輯每一行。

先用一個生活化的類比建立心智模型。傳統 npm 元件庫,就像你去訂了一份外送套餐:餐點很快到手、擺盤精美,但你只能吃它給的口味,想少放辣、換掉某樣配菜,只能拜託店家(對應套件開放的 props),廚房(原始碼)你進不去。而 shadcn/ui 像是店家直接把「食譜和備好的食材」交到你手上:你把它照抄進自己的廚房(你的 repo),之後要加料、換醬、改火候,全由你決定——你擁有的是可以無限改造的原始碼,而不是一份改不動的成品。

本系列以 TailwindCSS v4 為預設版本。本篇你將學到:

  • copy-paste 哲學:為什麼「把原始碼複製進你 repo」比「npm install」給你更大的掌控權
  • CLI 工作流:用 npx shadcn init 初始化、npx shadcn add 逐一安裝元件
  • 底層三件套:每個 shadcn/ui 元件如何建立在 Radix + cva + cn() 之上
  • 主題化與相容性:用 CSS 變數 / @theme 換膚,以及對 Tailwind v4、React 19 的相容

本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。

核心概念

shadcn/ui 到底是什麼(以及不是什麼)

理解 shadcn/ui 的第一步,是把它跟「傳統 npm 元件庫」明確區分開來。它不是你在 package.json 裡看得到的一個依賴項;它比較像一個官方維護的元件原始碼倉庫 + 一支幫你把程式碼搬進專案的 CLI。你不 import 它的套件,你 import 的是已經躺在你自己 components/ui/ 目錄裡的檔案

這個差別看似細微,卻改變了一切。我們用一張對照表看清楚它跟傳統元件庫的根本不同:

面向傳統 npm 元件庫shadcn/ui
取得方式npm install ui-libnpx shadcn add button
程式碼位置藏在 node_modules/複製進你的 components/ui/
引入方式import { Button } from "ui-lib"import { Button } from "@/components/ui/button"
誰擁有程式碼套件作者你自己
客製難度高(只能靠 props / 覆寫 CSS)低(直接改檔案)
升級方式npm update重新 add(或手動合併)
打包大小傾向全量引入按需複製,只有你用到的

表格裡最關鍵的一列是「誰擁有程式碼」。傳統元件庫的程式碼在 node_modules 裡,你看得到卻改不動——想調整某個 Dropdown 內部的 DOM 結構、想加一個它沒提供的變體,你只能在它開放的 API 範圍內打轉,或寫一堆 !important 去對抗它的預設樣式。shadcn/ui 則把這道牆整個拆掉:元件檔案就是你 repo 裡的一個普通 .tsx,你要改 class、加變體、換結構、甚至刪掉你不需要的部分,都跟改自己寫的程式碼一樣自然。

也因此,shadcn/ui 官方一直強調自己「不是一個元件庫,而是一套讓你建立自己元件庫的工具」。這句話一開始聽起來很饒舌,但理解它的差別很重要:元件庫是「別人幫你決定好、你只能用」的東西;而 shadcn/ui 給你的是一份高品質的起點原始碼加上搬運它的工具,真正的「你的元件庫」是你從這個起點開始,依專案需求持續修改、累積出來的。這也解釋了為什麼同樣用 shadcn/ui 的兩個專案,最後長得可能完全不一樣——因為每一份原始碼落地後,就走上了各自客製的路。

底層堆疊:Radix + cva + cn

shadcn/ui 之所以能「複製貼上就好用」,是因為它站在幾個成熟的基礎之上。每一個 shadcn/ui 元件,其實都是把上一篇談的「Radix + Tailwind」組合,再加上兩個工具預先拼裝好的成品:

一個 shadcn/ui 元件的組成(以 Button 為例):
──────────────────────────────────────────────
你的應用程式
   │  import { Button } from "@/components/ui/button"
   ▼
components/ui/button.tsx   ← 這段程式碼你擁有,可自由改
   ├─ cva  ……… 定義型別安全的變體(variant / size)
   ├─ cn() ……… 用 tailwind-merge + clsx 正確合併/去重 class
   └─ Radix Primitive …… 提供無障礙行為 + 鍵盤導覽(需要時)
   ▲
TailwindCSS  ← 所有樣式都是指向 CSS 變數的 utility class

拆解這幾個角色:

  • Radix UI:提供無障礙行為與鍵盤互動的底層原語。像 Dialog、Dropdown、Select 這類有互動的元件,shadcn/ui 直接用 Radix 做骨架,所以焦點管理、Esc 關閉、方向鍵導覽、正確的 aria-* 全都內建。(純展示型元件如 Button、Card 則不一定用到 Radix。)
  • cva(class-variance-authority):定義型別安全的變體系統。它讓一個元件能有 variant(default / outline / ghost / destructive…)與 size(sm / default / lg…)等維度,每個組合對應一組 Tailwind class,且有完整 TypeScript 型別提示。
  • cn() 工具:這是 shadcn/ui 初始化時會幫你放進 lib/utils.ts 的小函式,本質是 twMerge(clsx(...))clsx 負責條件式組合 class、tailwind-merge 負責解決 Tailwind class 衝突去重——確保「呼叫者傳入的 className 能正確覆寫元件預設樣式」而不會兩個 bg-* 打架。

換句話說,shadcn/ui 沒有發明新輪子,它是把業界最常見的高品質組合收斂成一套「有品味的預設 + 你能接手改的原始碼」。這也是為什麼上一篇說 Radix 是它的底層基礎。

值得特別理解的是 cvacn() 的分工,因為這兩者是你日後改元件時最常打交道的部分。cva 解決的是「一個元件要有很多長相」的問題:同樣一個 Button,你會需要主要按鈕、次要按鈕、外框按鈕、危險按鈕,還要有大中小三種尺寸——如果每種組合都手寫一串 class,不但重複,還很難維護。cva 讓你把這些長相宣告成結構化的 variantsize,呼叫時只要寫 buttonVariants({ variant: "outline", size: "sm" }) 就能組出對應的 class,而且 TypeScript 會擋掉你打錯的變體名。至於 cn(),它處理的是「兩串 Tailwind class 相遇時誰贏」的問題:Tailwind 的 utility 是後面的蓋前面的,但如果 p-4p-2 同時出現,瀏覽器只看 CSS 定義順序、不看你寫的順序,結果往往不如預期。tailwind-merge看懂這是同一類屬性、只保留最後一個,再配合 clsx 處理條件式(如 isActive && "bg-teal-50"),最終讓「呼叫者傳入的 className 一定能覆寫元件預設值」這件事變得可靠。理解了這兩者,你就抓到了所有 shadcn/ui 元件的共同骨架。

關鍵術語

  • copy-paste / 複製貼上:shadcn/ui 的核心哲學——透過 CLI 把元件原始碼複製進你的專案,而非安裝成 npm 依賴。
  • components/ui/:元件原始碼被複製進來的預設目錄,裡面的檔案由你擁有與維護。
  • components.json:shadcn/ui 的設定檔,記錄樣式風格、base color、是否用 CSS 變數、路徑別名(aliases)等。
  • 設計 token(design token):用 CSS 變數表達的語意化樣式值(如 --primary--radius),元件透過它們間接取色與尺寸,是主題化的基礎。
  • cva / cn():分別負責「變體邏輯」與「class 合併去重」的兩個工具,是 shadcn/ui 元件的樣式骨幹。

實作範例

理論看完,走一次真實的 shadcn/ui 工作流:初始化 → 加入元件 → 看它的原始碼結構 → 主題化。前提是你已有一個 React + TailwindCSS 的專案(以 Next.js 為例)。

步驟一:初始化專案

用 CLI 一鍵把 shadcn/ui 的基礎架構鋪進專案:

# 在既有的 React + TailwindCSS 專案根目錄執行
npx shadcn@latest init

它會用互動式問答建立設定(選 base color、是否用 CSS 變數等),完成後你的專案會多出這些檔案:

./
├── components.json          # shadcn/ui 設定檔
├── lib/
│   └── utils.ts             # cn() 工具函式(twMerge + clsx)
├── app/
│   └── globals.css          # CSS 變數 theme 系統(設計 token)
└── components/
    └── ui/                  # 之後 add 的元件都會放這裡

components.json 記錄了你的選擇,之後每次 add 都會照它的設定放檔案:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tailwind": {
    "config": "",            // v4 不需要 tailwind.config.js,可留空
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true,    // 使用 CSS 變數(強烈建議,主題化的基礎)
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui"
  }
}

步驟二:加入元件(copy-paste 的核心動作)

這是 shadcn/ui 最關鍵、也最能體現其哲學的指令。想要 Button,就 add 它——這不是安裝套件,而是把 Button 的原始碼複製一份到你的 components/ui/button.tsx:

# 把單一元件的原始碼複製進 components/ui/
npx shadcn@latest add button

# 也可以一次複製多個常用元件
npx shadcn@latest add card dialog form input select

# 不帶元件名,會列出所有可選元件讓你挑
npx shadcn@latest add

執行後,components/ui/button.tsx 就出現在你的 repo 裡了。接著就跟用任何本地元件一樣引入使用:

// 從「你自己的目錄」引入,不是從某個 npm 套件
import { Button } from "@/components/ui/button";

export function Toolbar() {
  return (
    <div className="flex gap-2">
      <Button>預設</Button>
      <Button variant="outline">外框</Button>
      <Button variant="destructive" size="sm">刪除</Button>
    </div>
  );
}

步驟三:看懂元件原始碼結構(以及如何自由改它)

打開剛複製進來的 components/ui/button.tsx,你會看到 cva + cn() 這套慣例。重點是:這整個檔案現在是你的,你可以直接編輯:

// components/ui/button.tsx — 這份原始碼在你的 repo 裡,可自由修改
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

// cva:定義變體。base 樣式在前,再依 variant / size 疊加
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean; // Radix Slot 模式:把樣式套用到子元素上
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    // cn():base(cva) → 呼叫者傳入的 className,順序確保覆寫可預測
    return (
      <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

因為程式碼是你的,新增一個官方沒有的變體只是改幾行的事——直接在 cvavariant 物件裡加一個 key:

// 直接在你的 button.tsx 裡新增品牌專屬變體
variant: {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  // ↓ 你自己加的,傳統 npm 元件庫做不到這麼乾脆
  brand: "bg-teal-600 text-white shadow-md shadow-teal-600/30 hover:bg-teal-700",
  soft: "bg-primary/10 text-primary hover:bg-primary/20",
},

之後就能 <Button variant="brand">,而且享有完整 TypeScript 提示。這種「改內部、加變體」的自由,正是 copy-paste 模式最大的價值。試想同樣的需求放在傳統元件庫上——你得先翻文件確認它有沒有開放 sxclassNames 之類的覆寫入口,再祈禱它的內部結構剛好允許你套上想要的樣式;而在 shadcn/ui,你只是打開檔案、加一行,毫無阻力。

更進一步,對於有互動的元件(例如 Dialog、Dropdown、Select),shadcn/ui 複製進來的檔案裡會看到它 import 對應的 Radix primitive,並把 Radix 的子元件(如 DialogPrimitive.OverlayDialogPrimitive.Content)包一層、套上 Tailwind 樣式後再 re-export。這代表:行為與無障礙全靠 Radix 保證(焦點陷阱、Esc 關閉、aria-*),而你能改的是那層 Tailwind 包裝的外觀。你甚至能在這層加上進出場動畫——shadcn/ui 的元件常直接用 data-[state=open]:animate-indata-[state=closed]:animate-out 這類 class,把上一篇提過的 data-* 狀態選擇器用得淋漓盡致。

步驟四:用 CSS 變數主題化(換膚不改元件)

注意上面元件用的是 bg-primarytext-primary-foreground 這類語意化 class,而不是寫死的 bg-blue-600。它們指向 globals.css 裡的設計 token(CSS 變數)。所以要換整站的品牌色與圓角,你不必動任何元件檔,只要改變數的值:

/* app/globals.css — 改這裡,全站元件一起換膚 */
@import "tailwindcss";

@theme {
  /* v4:用 @theme 把設計 token 暴露成 Tailwind 可用的變數 */
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --radius: var(--radius);
}

:root {
  --primary: 221.2 83.2% 53.3%;         /* 原本的藍 */
  --primary-foreground: 210 40% 98%;
  --radius: 0.5rem;
}

/* 只改這幾個值,就把品牌色換成青色、圓角變更圓 */
.brand-theme {
  --primary: 189 94% 43%;                /* 換成 teal/cyan 系 */
  --primary-foreground: 0 0% 100%;
  --radius: 1rem;
}

深色模式也是同一招:在 .dark 底下給同一組變數不同的值,元件不需要知道現在是明是暗,它只是忠實地讀 --primary。這就是「設計 token 集中管理」的威力——也正是下一篇《設計 Token》要深入的主題。

這裡有個容易被忽略卻極重要的觀念:shadcn/ui 的元件之所以「可換膚」,關鍵不在元件本身,而在它們刻意只用語意化的 class。如果某個元件裡寫的是 bg-blue-600,那它就永遠是藍的,你想換色只能逐一改元件;但因為它寫的是 bg-primary,而 bg-primary 又指向 --primary 這個變數,整條「元件 → utility → 變數」的鏈路就把「決定顏色」的權力集中到了 CSS 變數這一個地方。這正是為什麼你能在不動任何 .tsx 檔的前提下,幫整個產品換上全新品牌色、或做出 ocean / forest 之類的多主題切換——你改的永遠是那組 token,元件只是忠實的消費者。掌握這條鏈路,你就掌握了整套設計系統的「總開關」。

v4 相容小提醒:Tailwind v4 把設定從 JS config 移進 CSS,並可用 @theme 暴露 token,色彩也逐漸從 HSL 通道值轉向 oklch。shadcn/ui 已更新以支援 Tailwind v4 與 React 19,遷移時主要留意 globals.css 裡變數格式的包裝(hsl() / oklch())以及把 tailwind-merge 升級到支援 v4 的版本。

常見錯誤與最佳實踐

坑一:以為能像套件一樣 npm update 升級

最常見的誤解,是把 shadcn/ui 當成一般 npm 套件,期待 npm update 就能升級元件。不行——元件原始碼在你的 repo 裡、不在 node_modules,npm 根本管不到它。想拿官方對某元件的最新版,你只有兩條路:

# 方法 A:重新 add,用官方最新原始碼「覆蓋」你的檔案
npx shadcn@latest add button   # 會提示是否覆蓋既有檔案

# 方法 B:去官網看該元件最新原始碼,手動 diff 合併進你改過的版本

這是 copy-paste 模式的必然取捨:你換來了完全掌控權,代價是升級要自己來。正確做法是把 components/ui/ 納入 git,每次重新 add 前先確認工作區乾淨,覆蓋後用 git diff 清楚看到「官方改了什麼」,再決定哪些要保留、你自己的客製要不要重新套回去。

坑二:分不清「哪些要升級、哪些不用」

雖然元件檔不歸 npm 管,但它們相依的底層套件仍歸 npm 管——Radix primitives、cvatailwind-mergeclsx 這些還是 package.json 裡的依賴。所以版本同步的正確心智是:

shadcn/ui 專案的兩種「版本」:
──────────────────────────────────────────────
① 元件原始碼(components/ui/*.tsx)
   → 你擁有 → 靠「重新 add / 手動合併」更新
② 底層相依套件(@radix-ui/*, cva, tailwind-merge…)
   → npm 管 → 靠 npm update / 升級 package.json 更新
──────────────────────────────────────────────

尤其升級 Tailwind v3 → v4 時,務必一併把 tailwind-merge 升到支援 v4 的版本,否則 cn() 可能無法正確辨識 v4 的新 class 而去重錯誤,導致樣式覆寫失效。另外從 v3 遷到 v4 還要留意:v4 不再讀 tailwind.config.js(除非用 @config 明確引入),設定改由 CSS 承擔,所以 components.json 裡的 tailwind.config 可以留成空字串;而色彩變數的格式也可能從 HSL 通道值改為 oklch,遷移時要檢查 globals.csshsl() / oklch() 的包裝是否一致。建議所有升級都在一條乾淨的 git branch 上進行,並以官方 shadcn CLI 的遷移指令為準。

坑三:直接改壞了 cn() 的合併順序

cn(buttonVariants({ variant, size, className })) 的參數順序是有意義的:base 與變體樣式在前、呼叫者傳入的 className 在後,tailwind-merge 才能讓外部覆寫贏過預設。如果你手癢把順序調換、或用普通字串拼接取代 cn(),就會發生「明明傳了 bg-red-500 卻沒生效」的衝突 bug。保留 cn()、維持順序,是讓客製 class 可預測覆寫的關鍵。

最佳實踐小結

  • components/ui/ 納入版控:它是你的原始碼,升級靠 diff 而非 npm。
  • 主題化改變數、不改元件:換色/圓角動 CSS 變數(@theme / :root),讓元件保持語意化 class。
  • 區分兩種更新來源:元件靠重新 add,底層 Radix/cva/tailwind-merge 靠 npm 升級。
  • 升級 v4 別忘 tailwind-merge:讓 cn() 認得 v4 的新 class,避免覆寫失效。
  • 善用「你擁有原始碼」的自由:要加變體、改結構就大方改,這正是 shadcn/ui 存在的理由。

小結

這是 TailwindCSS 完整教學 系列的第三十一篇。上一篇 《無樣式元件庫》 我們認識了 Radix、Headless UI、React Aria 這些只借「行為與無障礙」、把樣式交給 Tailwind 的方案;而這一篇,我們看到 shadcn/ui 如何把「Radix + Tailwind + cva + cn()」整套拼裝成可複製貼上、你完全擁有原始碼的成品。回顧幾個重點:

  • copy-paste 哲學:shadcn/ui 不是 npm 套件,而是用 CLI 把元件原始碼複製進你的 components/ui/——從此那份程式碼屬於你,可自由改 class、加變體、換結構。
  • 底層三件套:每個元件建立在 Radix(無障礙行為)+ cva(型別安全變體)+ cn()(tailwind-merge + clsx 合併去重)之上。
  • CLI 工作流:npx shadcn init 初始化、npx shadcn add 逐一(或批次)複製元件。
  • 主題化與相容:用 CSS 變數 / @theme 集中管理設計 token,改變數即可全站換膚;已支援 Tailwind v4 與 React 19
  • 必然的取捨:元件無法 npm update,升級要靠重新 add 或手動合併——換來的是完全掌控權。

shadcn/ui 的主題化之所以優雅,關鍵在於那組語意化的 CSS 變數——--primary--background--radius… 它們把「顏色、間距、圓角」這些設計決策從元件裡抽出來、集中管理。這其實是一個更宏大的概念:設計 Token(Design Tokens)。下一篇 《設計 Token》 就要帶你系統性地理解:如何用一套 token 貫穿整個設計系統,讓品牌、明暗、多主題都能一鍵切換。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →