iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
自我挑戰組

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

Day 25: Storybook:讓 UI Kit 不再只活在 App.tsx 裡

  • 分享至 

  • xImage
  •  

做到 Day 24,CUI 已經累積不少元件。

從一開始的 Button,到後來的 Form、Table、Pagination,甚至昨天的 Dialog,我們已經做了不少 Accessibility 相關的實作。

目前的元件大致有:

Button
Input
Field
Textarea
Checkbox
Radio
Switch
Select

Badge
Alert
Empty State

Table
Pagination
Skeleton

Tooltip
Separator
Visually Hidden
Dialog

但一直以來,我們都把元件展示寫在:

src/App.tsx

例如:

<section>
  <h2>Button</h2>

  <Button variant="primary">
    Primary
  </Button>

  <Button variant="secondary">
    Secondary
  </Button>
</section>

接著又加入:

Input Demo
Field Demo
Table Demo
Pagination Demo
Tooltip Demo
Dialog Demo

一開始還滿方便的。

畢竟只要:

npm run dev

就能直接看到畫面。

但元件越來越多,App.tsx 也開始越來越像一個大型展示倉庫。

而且每次想測試不同狀態,都要回去修改 JSX。

例如想看看:

<Button disabled>
  儲存
</Button>

就得先把原本的程式碼改掉。

想測試:

<Button variant="destructive">
  刪除
</Button>

又要再改一次。

這樣其實不太適合長期維護 UI Kit。

所以今天我們要正式引入:

Storybook。

讓 CUI 的元件不再只是存在於程式碼裡,而是開始擁有自己的展示與文件環境。


1. Storybook 是什麼?

Storybook 是一個用來獨立開發、展示與測試 UI Component 的工具。

一般開發流程可能是:

Application
    │
    ├── Page
    │    ├── Form
    │    ├── Table
    │    └── Button
    │
    └── API / Business Logic

元件通常需要放進某個頁面,才能看到實際效果。

但 Storybook 可以把 Component 獨立出來:

Storybook
    │
    ├── Button
    │    ├── Primary
    │    ├── Secondary
    │    └── Disabled
    │
    ├── Input
    │    ├── Default
    │    └── Invalid
    │
    └── Dialog
         ├── Default
         └── Edit Form

我們不需要先建立完整頁面,就能直接觀察元件。

對 UI Kit 來說,這非常適合。


2. Storybook 在 CUI 架構中的位置

還記得 Day 1 的目標嗎?

CUI 並不是只提供 React Component。

我們希望它最後能服務不同使用者與環境:

                   CUI
                    │
             Design Tokens
                    │
            Component Contract
                    │
        ┌───────────┴───────────┐
        ↓                       ↓
      React                   CDN
        │                       │
        ↓                       ↓
    New Systems             Legacy Web

                    │
              Documentation
               ┌────┴────┐
               ↓         ↓
           Storybook   AI Skill
               │         │
               ↓         ↓
             Humans      AI

其中 Storybook 的定位是:

讓人類開發者理解、探索與使用 CUI 的文件入口。

它不只是展示元件長什麼樣。

未來還可以包含:

Props
Variants
States
Usage Examples
Accessibility Notes
Interaction Tests

所以今天建立 Storybook,也是讓 CUI 從元件集合走向 Design System 的重要一步。


3. 開始安裝 Storybook

目前專案使用:

React
TypeScript
Vite
Tailwind CSS v4
shadcn/ui
Base UI

Storybook 可以搭配 Vite 使用。

先從最新的 main 建立分支:

git switch main
git pull
git switch -c feat/storybook

接著執行:

npm create storybook@latest

依照 CLI 提示選擇與現有 React + Vite 專案相符的設定。

初始化完成後,Storybook 通常會新增:

.storybook/
├── main.ts
└── preview.ts

src/
└── stories/
    └── ...

package.json

實際檔案結構會依 Storybook 版本與初始化選項而有所不同。

同時,package.json 通常也會加入:

{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  }
}

完成後執行:

npm run storybook

正常就能開啟 Storybook 的開發介面。


4. Storybook 和 Vite 的角色不同

這裡先釐清一件事。

我們原本已經有:

npm run dev

為什麼還要:

npm run storybook

因為它們的用途不同。

Vite App:

展示應用程式
驗證元件組合
測試實際頁面

Storybook:

獨立展示 Component
管理不同 States
建立元件文件
測試 Component Behavior

所以:

App.tsx
≠
Storybook

Storybook 不是取代 React 開發環境。

而是提供一個專門給 UI Component 使用的工作環境。


5. 先讓 Storybook 讀取 CUI 的 CSS

初始化完成後,第一件事不是急著寫 Stories。

而是確認:

Storybook 有沒有正確載入我們的 Design Tokens 和 Tailwind CSS?

因為 CUI 的樣式都建立在:

src/index.css

以及:

src/styles/tokens.css
src/styles/theme.css

之上。

如果 Storybook 沒有載入這些 CSS,就可能出現:

Button 有出現
但完全沒有樣式

所以先檢查:

.storybook/preview.ts

並匯入全域 CSS:

import "../src/index.css"

這樣 Storybook 裡的元件才能使用相同的:

Design Tokens
Tailwind Utilities
Component Styles

也就是:

React App
      │
      ├── CUI CSS
      │
Storybook

兩邊使用同一份樣式來源。


6. Tailwind CSS v4 需要注意什麼?

目前 CUI 使用 Tailwind CSS v4。

所以 Storybook 不只是要匯入 CSS,還要確認 Vite 的 Tailwind 整合有正常運作。

如果原本專案的 Vite 已經使用:

import tailwindcss from "@tailwindcss/vite"

Storybook 也需要能正確處理這些樣式。

另外,Tailwind 的 Utility Class 需要在建置時被偵測到。

如果某些 Story 裡的 Class 沒有被包含在 Tailwind 掃描來源中,也可能導致樣式缺失。

因此今天至少要確認:

Primary Button
Secondary Button
Disabled Button
Focus Style

在 Storybook 和原本 App.tsx 裡的呈現一致。


7. Story 是什麼?

Storybook 裡最重要的概念就是:

Story。

一個 Story 可以理解成:

某個 Component 在特定情境下的展示案例。

例如 Button:

Button
├── Primary
├── Secondary
├── Destructive
├── Disabled
└── Icon Only

每一個都是不同的 Story。

而不是把所有 Button 狀態全部寫在同一個巨大 JSX 裡。


8. 建立第一個 Button Story

今天先從最熟悉的 Button 開始。

新增:

src/components/ui/button.stories.tsx

內容:

import type { Meta, StoryObj } from "@storybook/react-vite"

import { Button } from "./button"

const meta = {
  title: "Components/Button",
  component: Button,
  tags: ["autodocs"],

  args: {
    children: "儲存",
    variant: "primary",
    size: "md",
  },
} satisfies Meta<typeof Button>

export default meta

type Story = StoryObj<typeof meta>

export const Primary: Story = {}

export const Secondary: Story = {
  args: {
    variant: "secondary",
  },
}

export const Destructive: Story = {
  args: {
    variant: "destructive",
    children: "刪除",
  },
}

export const Disabled: Story = {
  args: {
    disabled: true,
  },
}

這裡有幾個新的概念:

Meta
StoryObj
args
tags

我們逐一看。


9. Meta:描述這個 Component

首先:

const meta = {
  title: "Components/Button",
  component: Button,
}

這是在告訴 Storybook:

這個 Story 屬於哪個 Component?
要放在側邊欄哪個位置?

例如:

Components
└── Button

而:

component: Button

讓 Storybook 知道我們正在展示哪個 React Component。


10. StoryObj:描述不同案例

接著:

type Story = StoryObj<typeof meta>

我們可以用這個型別建立不同 Story:

export const Primary: Story = {}

因為預設的 args 已經設定:

variant: "primary"

所以 Primary 不需要重複寫一次。

Secondary:

export const Secondary: Story = {
  args: {
    variant: "secondary",
  },
}

Disabled:

export const Disabled: Story = {
  args: {
    disabled: true,
  },
}

這樣每個案例都能獨立展示。


11. Args:讓 Story 可以被操作

Storybook 很方便的一點是:

args

它可以對應到 Component Props。

例如:

<Button
  variant="primary"
  size="md"
  disabled={false}
>
  儲存
</Button>

在 Storybook 裡,可以透過 Controls 調整:

variant
size
disabled
children

不需要回去改 JSX。

這正好解決我們之前在 App.tsx 遇到的問題。


12. 加入 Controls

雖然 Storybook 可以從 TypeScript Props 推導部分資訊,但我們也可以明確定義:

argTypes: {
  variant: {
    control: "select",
    options: [
      "primary",
      "secondary",
      "outline",
      "ghost",
      "destructive",
      "link",
    ],
  },

  size: {
    control: "select",
    options: [
      "sm",
      "md",
      "lg",
      "icon-sm",
      "icon-md",
      "icon-lg",
    ],
  },

  disabled: {
    control: "boolean",
  },
}

這樣使用者可以直接在 Storybook 裡:

variant → secondary
size → lg
disabled → true

然後立即看到畫面改變。

不過 Icon-only 的尺寸通常需要搭配 Icon 內容才有意義。

因此正式文件裡,Icon Button 最好仍然提供獨立 Story。


13. Storybook 開始反映 Component Contract

這裡就跟 Day 22 接起來了。

當時我們整理:

variant
size
disabled
data-cui-*

現在 Storybook 可以把這些規則直接呈現出來。

例如 Button 的 Controls:

Variant
Primary
Secondary
Outline
Ghost
Destructive
Link

Size
sm
md
lg

Disabled
true / false

如果未來有新開發者想使用 CUI,就不需要先翻完整的 button.tsx。

他可以直接在 Storybook 裡操作。

這也是 Component Contract 文件化的第一步。


14. 不只是展示外觀,也要展示語意

例如 Icon-only Button。

我們不能只做:

<Button size="icon-md">
  <SearchIcon />
</Button>

因為這顆 Button 缺少 Accessible Name。

所以應該建立:

export const IconOnly: Story = {
  args: {
    size: "icon-md",
    "aria-label": "搜尋",
  },

  render: (args) => (
    <Button {...args}>
      <SearchIcon aria-hidden="true" />
    </Button>
  ),
}

這樣 Storybook 展示的不是只有:

漂亮的 Icon Button

而是:

正確使用的 Icon Button

這點對 Accessible UI Kit 特別重要。


15. Story 不應該只是 Screenshot

以前我們在 App.tsx 展示:

Default
Disabled
Invalid

主要是看外觀。

但 Storybook 的價值還可以更進一步。

例如:

Button / Disabled

不只是展示灰色 Button。

也要讓開發者理解:

Disabled Button
→ 無法觸發操作
→ 原生 disabled
→ 通常不進入 Tab 順序

同樣:

Input / Invalid

不只是紅色邊框。

還應該包含:

aria-invalid
aria-describedby
FieldError

也就是:

Story 應該展示正確的使用方式,而不是只有視覺狀態。


16. 接著建立 Input Story

Button 完成後,第二顆可以選 Input。

但我不想只展示:

<Input />

因為我們在 Day 12 已經整理過:

Input 不應該脫離 Label 與 Field 的語意關係。

所以更適合展示:

Input
├── Default
├── With Description
├── Required
├── Invalid
└── Disabled

例如:

<Field>
  <FieldLabel htmlFor="storybook-email">
    電子郵件
  </FieldLabel>

  <Input
    id="storybook-email"
    type="email"
    placeholder="name@example.com"
  />

  <FieldDescription>
    請輸入常用的電子郵件地址。
  </FieldDescription>
</Field>

這樣我們展示的是一個完整且合理的 Form Field。

而不是只有一個孤零零的 Input。


17. Invalid Story 更值得留下來

例如:

<Field data-invalid="true">
  <FieldLabel htmlFor="invalid-email">
    電子郵件
  </FieldLabel>

  <Input
    id="invalid-email"
    type="email"
    aria-invalid="true"
    aria-describedby="invalid-email-error"
    defaultValue="abc"
  />

  <FieldError id="invalid-email-error">
    請輸入有效的電子郵件地址。
  </FieldError>
</Field>

這個 Story 可以同時展示:

Visual Error State
+
ARIA Invalid State
+
Error Association

未來有人問:

CUI 的表單錯誤應該怎麼寫?

就可以直接參考這個 Story。


18. Storybook 不只放 Primitive

目前我們除了單一 Component,也已經有:

Form Pattern
Data List Pattern

所以 Storybook 的側邊欄不一定全部放在:

Components

可以開始分層:

Components
├── Button
├── Input
├── Badge
├── Alert
├── Table
├── Pagination
├── Tooltip
└── Dialog

Patterns
├── Form
└── Data List

這樣可以讓開發者理解:

Component
→ 單一可重用元件

Pattern
→ 多個元件的組合方式

這跟我們 Day 20、Day 21 討論的架構完全一致。


19. Data List 很適合成為 Story

例如:

Patterns / Data List

可以展示:

Default
Loading
Empty
No Results
Error

這樣就不需要每次在 App.tsx 裡:

setDataState("loading")

再切:

setDataState("error")

而是直接選:

Data List / Loading

就能看到該狀態。

這也讓 Day 21 的 Data State 真正成為可維護的文件案例。


20. Dialog 的 Story 要注意什麼?

昨天剛完成 Dialog。

它跟 Button 不太一樣。

因為 Dialog 是 Interactive Component。

所以 Story 不能只是把:

<DialogContent>

單獨丟在畫面上。

它需要完整的:

Dialog Root
Trigger
Content

例如:

Dialog / Edit Form

Story 裡可以提供:

[編輯資料]

讓使用者真的按下去。

接著測試:

Open
Focus
Tab
Escape
Close

這樣 Storybook 就不只是展示外觀,也能作為互動測試的入口。


21. Tooltip 也是一樣

Tooltip 的 Story 也應該包含:

Trigger
Content

例如:

Tooltip / Icon Button

展示:

Hover
Focus
Escape

而且 Trigger 必須有 Accessible Name。

否則我們可能做出一個:

Tooltip 看起來正常

但:

Icon Button 本身沒有名稱

的錯誤示範。

所以 Storybook 裡的每個 Story,也要遵守 CUI 的 Accessibility Rules。


22. Storybook 的 Docs 頁面

前面 Button Story 裡有:

tags: ["autodocs"]

這可以讓 Storybook 在支援的設定下,自動產生文件頁面。

例如:

Button
│
├── Description
├── Props / Controls
├── Primary
├── Secondary
├── Disabled
└── Examples

這讓我們不用從零開始寫每一個元件的展示頁。

但自動產生的文件不一定知道:

什麼時候該用 Primary?

什麼時候不該用 Disabled?

Icon-only Button 為什麼需要 Accessible Name?

這些仍然需要我們自己補充。


23. 自動文件不等於完整文件

例如 Button 的 API 文件可能告訴你:

variant: primary | secondary | outline ...

但它不會自動解釋:

同一個區域不應該放太多 Primary Action

或:

Disabled Button 可能讓使用者不知道為什麼不能操作

所以未來 Storybook 文件至少可以分成:

API
→ 怎麼使用?

Usage
→ 什麼時候使用?

Accessibility
→ 使用時要注意什麼?

今天先把 Story 和 Controls 建起來。

明天 Day 26 再深入 Accessibility 文件與測試。


24. Storybook 和 CUI 的 Single Source

這裡還有一件很重要的事。

我們不是在 Storybook 裡重新寫一套 Button。

而是直接:

import { Button } from "./button"

也就是:

src/components/ui/button.tsx
           │
           ├── React App
           │
           └── Storybook

兩邊使用同一個 Component。

所以如果未來修改:

Button Variant
Focus Style
Design Token

Storybook 會反映相同的元件實作。

這才符合我們的 Single Source 原則。


25. App.tsx 現在可以刪掉嗎?

我覺得還不用急。

目前 App.tsx 可能還有很多:

Component Demo
Form Demo
Data List Demo

我們可以逐步把展示內容移到 Storybook。

例如今天先完成:

Button
Input
Badge
Dialog

之後再補:

Table
Pagination
Data List

等 Storybook 已經能完整承擔元件展示責任,再整理 App.tsx。

這樣比今天直接把 App.tsx 全刪掉安全很多。


26. Storybook 的目錄怎麼安排?

目前可以先採用 Colocation:

src/
└── components/
    └── ui/
        ├── button.tsx
        ├── button.stories.tsx
        │
        ├── input.tsx
        ├── input.stories.tsx
        │
        ├── badge.tsx
        ├── badge.stories.tsx
        │
        ├── dialog.tsx
        └── dialog.stories.tsx

也就是:

Component
+
Story

放在一起。

好處是修改元件時,很容易找到對應展示案例。

而比較大型的 Pattern,則可以另外整理:

src/
└── stories/
    └── patterns/
        ├── form.stories.tsx
        └── data-list.stories.tsx

目前不用為了 Storybook 重新搬動所有元件。

先維持簡單就好。


27. 今天先不要把所有元件都補完

CUI 已經有十幾顆元件。

如果今天一次建立:

Button Stories
Input Stories
Textarea Stories
Checkbox Stories
Radio Stories
Switch Stories
Select Stories
Badge Stories
Alert Stories
Table Stories
Pagination Stories
Tooltip Stories
Dialog Stories

文章很容易變成:

今天寫了很多 Story 檔案。

但其實沒有真正理解 Storybook。

所以今天我想先完成幾個代表性案例:

Story 目的
Button Args / Controls / Variants
Input + Field Label / Description / Invalid
Badge Semantic Status Variants
Dialog Interactive Component
Data List Composition Pattern

這五組已經足夠讓我們理解 Storybook 的基本架構。

其他元件可以沿用相同方式逐步補齊。


28. 今天的檢查重點

完成後,我們可以確認:

Storybook 環境

  • [ ] Storybook 可以正常啟動
  • [ ] Vite / TypeScript 設定正常
  • [ ] Tailwind CSS 正常載入
  • [ ] CUI Design Tokens 正常套用

Stories

  • [ ] Button Variants 正常顯示
  • [ ] Controls 可以修改 Props
  • [ ] Input 的 Label / Error 關聯正確
  • [ ] Badge 使用 Semantic Status Colors
  • [ ] Dialog 可以正常開啟與關閉
  • [ ] Data List 可以展示不同狀態

架構

  • [ ] Stories 使用原本的 CUI Components
  • [ ] 沒有複製另一份元件實作
  • [ ] Storybook 不依賴 App.tsx 才能展示元件

29. 最後執行檢查

首先確認 Storybook 開發環境:

npm run storybook

再測試 Storybook 的正式建置:

npm run build-storybook

接著執行原本專案的檢查:

npm run build
npm run lint
npm run check:contrast

如果專案的 Lint 規則開始掃描 Storybook 自動產生的檔案,也需要確認相關設定是否合理。

例如:

storybook-static/

這類建置輸出不應該被當成需要維護的原始碼。

最後:

git status
git diff

確認新增的 Storybook 設定與 Stories。

Commit 可以:

git add .
git diff --staged
git commit -m "feat: integrate storybook for component documentation"

Day 25 完成

今天我們終於讓 CUI 有了正式的元件展示環境。

以前:

App.tsx
  │
  ├── Button Demo
  ├── Input Demo
  ├── Table Demo
  └── Dialog Demo

現在開始變成:

Storybook
  │
  ├── Components
  │   ├── Button
  │   ├── Input
  │   ├── Badge
  │   └── Dialog
  │
  └── Patterns
      └── Data List

這不只是把展示畫面搬到另一個工具。

更重要的是,我們開始把元件的:

Props
Variants
States
Usage

整理成開發者可以直接探索的文件。

而且 Storybook 使用的是同一份 CUI Source。

所以:

Component
→ Implementation

Story
→ Example

Controls
→ Exploration

Docs
→ Documentation

各自有清楚的責任。

今天最值得記住的是:

Storybook 不是另一套 UI,而是讓既有 UI Components 變得更容易理解、測試與維護。


Next:Day 26

今天我們已經有:

Storybook
+
Stories
+
Controls

但 CUI 的主題一直都是:

Accessibility。

所以明天不能只停留在:

元件長得對不對?

還要進一步確認:

Accessible Name 正確嗎?

ARIA Relationship 正確嗎?

Color Contrast 有問題嗎?

Keyboard 可以操作嗎?

Dialog 的 Focus Management 正常嗎?

不同 State 有沒有被完整測試?

Day 26 我們會正式把 Accessibility Testing 接進 Storybook。

除了自動化檢查,也會整理哪些問題仍然需要人工測試。

因為:

Accessibility Test Pass,不代表整個 Component 就一定 Accessible。

Day 26:

Storybook + Accessibility:元件不只要好看,還要能被正確使用。


上一篇
Day 24: Dialog:打開一個視窗之後,Focus 到底該去哪?
下一篇
Day 26: Storybook + Accessibility:測試通過,就代表無障礙了嗎?
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言