iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 4 篇

Day 4: 拆開 shadcn/ui Button:一顆元件裡到底有什麼?

  • 分享至 

  • xImage
  •  

Day 2 建好了 Vite + React + TypeScript 專案,Day 3 也了解了 shadcn/ui 和一般 Component Library 的不同

講了三天 UI Kit,目前的 Component 數量:

0...

所以今天終於可以加入第一顆 Component 了 XDD

在加入 Button 之前,先初始化 shadcn/ui

目前的專案只是單純透過 Vite 建立的 React + TypeScript Project,因此先執行:
npx shadcn@latest init

結果第一次就失敗了,LI 顯示兩個問題:

× Validating Tailwind CSS. Found v4.
× Validating import alias.

shadcn/ui 還需要一些前置設定,包括 Tailwind CSS,以及 @/* 的 import alias。

這也讓 Day 3 提到的事情變得比較具體:shadcn/ui 並不是安裝一個 package,接著從 package 裡 import component 就結束了。

它會把 Component Code 加入自己的 Project,因此也需要知道 Project 的 Style、路徑等設定。

完成 Tailwind CSS 與 alias 設定後,再次執行:

npx shadcn@latest init

這次就順利通過檢查了。

目前這個 UI Kit 選擇:

Component Library:Base UI
Preset:Nova

這些只是目前 Component 的起點,之後 UI Kit 還會建立自己的 Design Tokens 與 Component Rules,所以暫時不急著修改它的外觀。

完成後CLI呈現:
https://ithelp.ithome.com.tw/upload/images/20260918/201401088LvsEd8MTm.png

終於加入第一顆 Button

npx shadcn@latest add button

Project 裡直接出現了:

src/
└─ components/
└─ ui/
└─ button.tsx

也就是 Day 3 提到的 Open Code。

Button 的 Source Code 現在真的在自己的 Project 裡,可以直接打開、閱讀,甚至修改。

接著把它放進 App.tsx:

import { Button } from "@/components/ui/button"

function App() {
  return (
    <main>
      <Button>Default</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="destructive">Delete</Button>
      <Button disabled>Disabled</Button>
    </main>
  )
}

export default App

第一顆 Component 正式出現!!

這顆 Button 是怎麼組成的?

暫時把 Style 縮起來,整個 Button 的結構其實沒有想像中複雜:

import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

const buttonVariants = cva(
  // styles...
)

function Button({
  className,
  variant = "default",
  size = "default",
  ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
  return (
    <ButtonPrimitive
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }

大致可以拆成:

               Button
                 │
   ┌─────────────┼─────────────┐
   ▼             ▼             ▼

ButtonPrimitive buttonVariants cn()
│ │ │
Base UI CVA Class 合併
│ │
Behavior / Variant
Semantics Size / Style

原本看起來很大一顆的 Button,其實是由幾個不同的角色組合起來的

ButtonPrimitive:Button 的底層

首先是:

import { Button as ButtonPrimitive } from "@base-ui/react/button"

這顆 Button 並不是直接從:

開始建立,而是使用 Base UI 提供的 Button Primitive。

這也對應到初始化 shadcn/ui 時選擇的:

Component Library:Base UI

所以目前可以先簡化理解成:

Base UI Button
↓
shadcn/ui 加上 Style 與 Component API
↓
目前 Project 裡的 Button

Base UI 這一層比較接近 Button 的 Behavior 與 Semantics 基礎,而 shadcn/ui 再往上加入 Style、Variant 等設定。

這也是為什麼在 App.tsx 使用 Button 時,不需要知道底下到底用了 Base UI 還是其他實作:

<Button variant="outline">
  取消
</Button>

使用 Component 只需要面對 Button 提供的 API。

CVA:Button 為什麼有這麼多版本?

接下來是整份檔案裡最大的一塊:

const buttonVariants = cva(
  "...",
  {
    variants: {
      variant: {
        default: "...",
        outline: "...",
        secondary: "...",
        ghost: "...",
        destructive: "...",
        link: "...",
      },
      size: {
        default: "...",
        xs: "...",
        sm: "...",
        lg: "...",
        icon: "...",
        "icon-xs": "...",
        "icon-sm": "...",
        "icon-lg": "...",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

這裡使用的是 CVA(Class Variance Authority)。

如果把 Class 全部拿掉,只看結構,就很好理解:

buttonVariants
│
├─ Base Style
│
├─ variant
│ ├─ default
│ ├─ outline
│ ├─ secondary
│ ├─ ghost
│ ├─ destructive
│ └─ link
│
├─ size
│ ├─ default
│ ├─ xs
│ ├─ sm
│ ├─ lg
│ └─ icon...
│
└─ defaultVariants

因此當使用:

可以簡單理解成 CVA 幫忙組合:

Button 共用 Style
+
outline Style
+
sm Style

這裡也開始出現一個 UI Kit 很重要的概念。

variant 和 size 已經不只是 CSS。

它們開始成為 Component API

使用 Button 的人不需要自己記得 Outline Button 要加哪些 Border、Background、Hover Class,只需要使用:

variant="outline"

就可以了。

TypeScript 也參與了 Component API

Button 的 Props 定義也很有意思:

ButtonPrimitive.Props &
VariantProps

它其實來自兩個地方:

ButtonPrimitive.Props
+
VariantProps
↓
Button Props

Base UI 提供 Button 本身需要的 Props,而 CVA 定義的 variant、size 也會成為 Component Props 的一部分。

因此 TypeScript 可以知道:

<Button variant="outline" />

是合法的,但亂寫:

<Button variant="banana" />

就不在定義好的 Variant 裡。

這也是 UI Kit 使用 TypeScript 很實際的一個地方:Component 的使用規則,本身就可以成為型別的一部分。

Button 不需要知道 primary 最後究竟是哪一個 Hex Color,只需要知道:

我現在是 Primary Button,所以使用 Primary。

這其實已經碰到 Design Tokens 的概念了。

不過這裡先忍住,不急著開始改 Button 的顏色。

因為如果 Button、Input、Select、Dialog 都需要共用同一套視覺規則,比起每顆 Component 各自修改,更應該先建立整套 UI Kit 共用的 Tokens。

這就是 Day 5 要處理的事情。

Accessibility Check:先不用滑鼠

既然這套 UI Kit 的其中一個目標是 Accessibility,那麼加入 Component 後,除了看它的 Code 和外觀,也希望養成一個習慣:

實際操作看看。

先替 Button 加上一個簡單的事件,確認它有沒有真的被觸發:

<Button onClick={() => console.log("Button clicked!")}>
Default

接著把手從滑鼠移開,使用鍵盤測試。

測試 Enter 時,還發現一個有趣的小地方。

使用滑鼠按下 Button 時,可以看到 Button 有一個很輕微的向下位移效果;但使用 Enter 觸發時,雖然事件確實有被觸發,卻沒有看到一樣的動畫。

回頭找 Button Style,可以看到:

active:not-aria-[haspopup]:translate-y-px

這個位移是和 CSS 的 :active State 有關。

也因此,Button 有沒有成功被 Keyboard 觸發,和滑鼠、鍵盤是否呈現完全相同的按壓視覺效果,是兩件不同的事情。

Accessibility Check 真正要確認的是:沒有滑鼠的情況下,使用者是否仍然能夠操作這顆 Button。

這次也第一次很明顯地看到:

Focus State ≠ Active State ≠ Disabled State

一顆看似簡單的 Button,其實有不少不同狀態。

Component 本身有良好的 Accessibility 基礎,不代表任何使用方式都會自動變成 Accessible。

如何使用 Component,一樣是 UI Kit 需要定義的規則。

一顆 Button,其實已經很像一個小型 UI Kit

原本以為今天只是:

npx shadcn@latest add button

結果打開 Source Code 後,一顆 Button 裡已經看到了:

Button
│
├─ Component Base
├─ Props / Type
├─ Variant
├─ Size
├─ Component API
├─ Tailwind CSS
├─ Design Tokens
├─ State
└─ Accessibility

Source Code 已經在 Project 裡了,接下來當然可以直接修改。

但現在反而不急著動 Button。

如果每做一顆 Component,就各自決定自己的顏色、圓角、間距與 Focus Style,很快又會回到一開始想解決的問題:

每個地方都有自己的規則。

所以在正式把 shadcn/ui Button 變成「自己的 Button」之前,下一步先處理所有 Components 都會共用的東西。

Day 5: 在做元件之前,先建立自己的 Design Tokens


上一篇
Day 3: shadcn/ui 到底是什麼?它和一般元件庫有什麼不同?
下一篇
Day 5: 在做元件之前,先建立自己的 Design Tokens
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言