前幾天,我們花了不少時間處理表單。
從 Input、Field、Textarea,一路做到 Checkbox、Radio、Switch、Select,最後在 Day 16 把這些元件組成一套完整的 Form Pattern。
但一個系統不會只有「輸入」。
使用者送出資料後,我們還需要告訴他:
這些都屬於另一類很常見的 UI:
Status & Feedback
所以今天先暫時離開表單,來做三種常見的狀態元件:
同時也要處理一個很重要的 Accessibility 原則:
不能只使用顏色來傳達資訊。
今天的 CUI 會加入:
Status / Feedback
│
├── Badge
│ ├── neutral
│ ├── primary
│ ├── success
│ ├── warning
│ ├── error
│ └── outline
│
├── Alert
│ ├── info
│ ├── success
│ ├── warning
│ └── error
│
└── Empty State
├── Icon
├── Title
├── Description
└── Action
其中 Badge 和 Alert 還會正式使用 Day 6 建立的 Semantic Color Tokens。
假設一個申請系統裡有三筆資料:
● 王小明
● 陳小華
● 林小美
並且規定:
綠色 = 已通過
黃色 = 待審核
紅色 = 已退回
對可以正常辨識這些顏色的人來說,也許一眼就看懂了。
但是如果使用者無法辨識這些顏色,畫面可能只剩:
● 王小明
● 陳小華
● 林小美
原本最重要的「狀態」資訊就消失了。
因此,比較好的方式是:
王小明 [ 已通過 ]
陳小華 [ 待審核 ]
林小美 [ 已退回 ]
即使把所有顏色拿掉:
[ 已通過 ]
[ 待審核 ]
[ 已退回 ]
資訊依然存在。
也就是:
文字 → 主要資訊
顏色 → 額外的視覺提示
而不是:
顏色 → 唯一的資訊來源
這也是今天設計 Badge 和 Alert 時最重要的原則。
先加入 shadcn/ui 的 Badge:
npx shadcn@latest add badge
原本 shadcn/ui 提供的 variant 比較偏向視覺樣式:
default
secondary
destructive
outline
ghost
link
但 CUI 目前希望 Badge 主要拿來呈現「狀態」。
例如:
[ 進行中 ]
[ 已通過 ]
[ 待審核 ]
[ 已退回 ]
因此最後將 API 整理成:
neutral
primary
success
warning
error
outline
使用方式:
<Badge variant="neutral">
未設定
</Badge>
<Badge variant="primary">
進行中
</Badge>
<Badge variant="success">
已通過
</Badge>
<Badge variant="warning">
待審核
</Badge>
<Badge variant="error">
已退回
</Badge>
<Badge variant="outline">
已結束
</Badge>
這樣看到:
<Badge variant="success">
就可以直接理解它代表的是 semantic status,而不只是「某一種綠色」。
做到這裡時遇到了一個問題。
我原本在 Badge 裡寫:
success:
"bg-success-container text-on-success-container",
warning:
"bg-warning-container text-on-warning-container",
error:
"bg-error-container text-on-error-container",
結果畫面完全沒有顏色。
為什麼?
因為雖然 Day 6 已經建立:
--cui-color-success-container
--cui-color-on-success-container
--cui-color-warning-container
--cui-color-on-warning-container
--cui-color-error-container
--cui-color-on-error-container
但 Tailwind 還不知道這些 Token 的存在。
因此需要在 @theme inline 建立對應:
/* CUI Status */
--color-success: var(--cui-color-success);
--color-on-success: var(--cui-color-on-success);
--color-success-container: var(--cui-color-success-container);
--color-on-success-container: var(--cui-color-on-success-container);
--color-warning: var(--cui-color-warning);
--color-on-warning: var(--cui-color-on-warning);
--color-warning-container: var(--cui-color-warning-container);
--color-on-warning-container: var(--cui-color-on-warning-container);
--color-error: var(--cui-color-error);
--color-on-error: var(--cui-color-on-error);
--color-error-container: var(--cui-color-error-container);
--color-on-error-container: var(--cui-color-on-error-container);
這樣才能使用:
bg-success-container
text-on-success-container
bg-warning-container
text-on-warning-container
bg-error-container
text-on-error-container
這裡其實讓 Day 6 做的 Design Tokens 真正開始發揮作用。
元件不需要自己決定:
bg-green-100
text-green-800
而是:
bg-success-container
text-on-success-container
也就是:
Primitive Color
↓
Semantic Token
↓
Component
Badge 只需要知道:
我現在是 Success。
至於 Success 到底是哪一個綠色,應該由 Design Token 決定。
最後的 Badge variants 大致如下:
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
neutral:
"bg-muted text-muted-foreground",
primary:
"bg-primary text-primary-foreground",
success:
"bg-success-container text-on-success-container",
warning:
"bg-warning-container text-on-warning-container",
error:
"bg-error-container text-on-error-container",
outline:
"border-border text-foreground",
},
},
defaultVariants: {
variant: "neutral",
},
}
)
並延續 CUI Component Contract:
<span
data-slot="badge"
data-cui-slot="badge"
data-cui-variant={variant}
>
最後 DOM 可以得到:
<span
data-cui-slot="badge"
data-cui-variant="success"
>
已通過
</span>
未來 Legacy 頁面也可以使用同樣的 Contract。
這次 shadcn/ui 產生的 Badge 使用了 Base UI 的 useRender,可以支援 polymorphic rendering。
但做到 CUI 時,我重新想了一次:
Status Badge 真的需要變成各種 HTML Element 嗎?
目前 CUI 對 Badge 的定位很單純:
Status Badge
→ 非互動
→ 顯示狀態
→ span
所以最後反而選擇固定使用 <span>。
如果未來某個東西需要點擊:
[ 查看申請 ]
那它本質上可能更接近 Link 或 Button,而不是讓 Badge 偷偷變成互動元件。
這也是「Own the code」的好處。
使用 shadcn/ui 並不代表所有原始設計都必須留下。
我們可以依照自己的 Design System 重新決定 Component Contract。
Badge 適合很短的狀態:
[ 已通過 ]
但有些情況需要更完整的訊息:
⚠ 資料尚未完成
還有 3 個必填欄位需要填寫。
這時就適合使用 Alert。
加入:
npx shadcn@latest add alert
CUI 最後定義四種:
info
success
warning
error
例如:
<Alert variant="warning">
<AlertTitle>
資料尚未完成
</AlertTitle>
<AlertDescription>
還有 3 個必填欄位需要填寫。
</AlertDescription>
</Alert>
這次 Alert 不重新定義顏色,而是沿用剛才接好的 Status Tokens:
variant: {
info:
"border-primary/30 bg-primary-container text-on-primary-container",
success:
"border-success/30 bg-success-container text-on-success-container",
warning:
"border-warning/30 bg-warning-container text-on-warning-container",
error:
"border-error/30 bg-error-container text-on-error-container",
}
Info 使用 Primary Container,因此另外將 Day 6 的 Primary Container 接入 Tailwind:
--color-primary-container:
var(--cui-color-primary-container);
--color-on-primary-container:
var(--cui-color-on-primary-container);
現在 Status Feedback 和原本的 Design Tokens 就正式串起來了。
role="alert" 不是 Alert 元件的必備屬性這次看 shadcn/ui 原始 Alert 時,我注意到它直接寫:
<div role="alert">
但 CUI 最後把這個預設拿掉了。
原因是:
長得像 Alert,不代表一定需要 ARIA
alertrole。
例如頁面一開始就有:
⚠ 注意
申請資料送出後將無法修改。
這是頁面原本就存在的重要資訊。
它可以只是:
<Alert variant="warning">
不一定需要:
role="alert"
但是另一種情況:
使用者按下:
[ 儲存 ]
接著畫面突然出現:
儲存失敗
無法儲存資料,請稍後再試。
這種操作後動態出現的重要訊息,才可能適合:
<Alert
variant="error"
role="alert"
>
因此 CUI 沒有把 role="alert" 寫死。
而是讓使用者依情境決定:
Alert visual style
≠
ARIA alert semantics
這點我覺得非常重要。
Accessibility 並不是看到一個元件叫 Alert,就把所有 ARIA 全塞進去。
接著替 Alert 加上 Icon:
<Alert variant="warning">
<TriangleAlert aria-hidden="true" />
<AlertTitle>
資料尚未完成
</AlertTitle>
<AlertDescription>
還有 3 個必填欄位需要填寫。
</AlertDescription>
</Alert>
四種狀態分別使用:
Info → CircleInfo
Success → CircleCheck
Warning → TriangleAlert
Error → CircleX
但是 Icon 都加:
aria-hidden="true"
因為真正的資訊已經存在於:
資料尚未完成
如果 Icon 沒有提供額外資訊,就不需要讓 Screen Reader 再讀一次。
所以現在 Alert 的資訊結構變成:
Status
│
┌───────┼───────┐
↓ ↓ ↓
Color Icon Text
顏色與 Icon 都是輔助。
真正不可缺少的是 Text。
Alert 一樣加入:
data-cui-slot="alert"
data-cui-variant={variant}
而內部:
data-cui-slot="alert-title"
data-cui-slot="alert-description"
data-cui-slot="alert-action"
因此未來 Legacy 可以寫成:
<div
data-cui-slot="alert"
data-cui-variant="warning"
>
<div data-cui-slot="alert-title">
資料尚未完成
</div>
<div data-cui-slot="alert-description">
還有 3 個必填欄位需要填寫。
</div>
</div>
等到後面建立 cui.css,React 和 Legacy 就能逐漸共用同一套視覺規則。
最後來做 Empty State。
這次沒有使用 shadcn/ui,而是自己從零建立一個 Pattern。
不過在開始之前,先區分三種很容易混在一起的狀態:
Empty
→ 原本就沒有資料
No Results
→ 有資料,但搜尋 / 篩選後沒有符合結果
Error
→ 應該有資料,但載入失敗
例如:
尚無收藏項目
你目前還沒有收藏任何內容。
[ 瀏覽內容 ]
找不到符合「React」的結果
請嘗試其他關鍵字。
[ 清除搜尋 ]
無法載入收藏內容
請稍後再試。
[ 重新載入 ]
雖然畫面看起來都是「沒有東西」,但原因與使用者下一步完全不同。
今天先處理真正的 Empty State。
Loading / Empty / Error 的完整 Data List Pattern,後面還會再處理。
建立:
src/components/ui/empty-state.tsx
拆成:
EmptyState
├── EmptyStateIcon
├── EmptyStateTitle
├── EmptyStateDescription
└── EmptyStateAction
例如:
<EmptyState>
<EmptyStateIcon aria-hidden="true">
<InboxIcon />
</EmptyStateIcon>
<EmptyStateTitle>
尚無收藏項目
</EmptyStateTitle>
<EmptyStateDescription>
你目前還沒有收藏任何內容,可以從瀏覽內容開始加入收藏。
</EmptyStateDescription>
<EmptyStateAction>
<Button>
瀏覽內容
</Button>
</EmptyStateAction>
</EmptyState>
值得注意的是:
沒有 CVA
沒有 Base UI
沒有 state
沒有 JavaScript interaction
它只是一個內容組合 Pattern。
這也提醒我一件事:
不是所有 Design System 元件都需要高度抽象。
有時候最簡單的 HTML 結構,就是最好的 Component API。
通常不需要。
如果它只是正常的頁面內容:
<div>
<h3>尚無收藏項目</h3>
<p>
你目前還沒有收藏任何內容。
</p>
<button>
瀏覽內容
</button>
</div>
這些原生 HTML semantics 已經可以表達內容。
因此沒有必要因為它「很重要」就加:
role="alert"
或:
aria-live="polite"
這和前面的 Dynamic Alert 剛好形成對比:
一般 Empty State
→ 正常頁面內容
→ 通常不需要 Live Region
操作後突然出現的重要錯誤
→ Dynamic Feedback
→ 可能需要 role="alert"
ARIA 不是越多越好。
有些 Empty State 有很清楚的下一步:
尚無收藏項目
[ 瀏覽內容 ]
但有些沒有。
例如:
尚無歷史紀錄
完成操作後,相關紀錄將顯示於此。
這種情況其實沒有必要硬塞一顆:
[ 知道了 ]
因此:
<EmptyStateAction>
在 CUI 裡是 optional。
Pattern 應該提供合理的組合方式,而不是要求每個畫面長得完全一樣。
完成後重新整理:
| Component | 用途 | Example |
|---|---|---|
| Badge | 簡短 inline status | 已通過、待審核 |
| Alert | 一段重要 feedback | 儲存成功、資料尚未完成 |
| Empty State | 解釋為什麼沒有內容 | 尚無收藏項目 |
它們看起來都在表達「狀態」,但層級完全不同。
Badge
→ What is its status?
Alert
→ What happened / what should I know?
Empty State
→ Why is there nothing here / what can I do next?
這樣之後做真正的系統頁面時,就比較不容易什麼東西都拿 Alert 解決。
回頭看今天的程式碼,好像只是新增:
Badge
Alert
Empty State
但我覺得更重要的是,我們開始替 CUI 建立一套一致的「狀態語言」。
例如:
success
warning
error
不是:
green
yellow
red
因為:
green
只描述「它長什麼顏色」。
而:
success
描述的是:
這個顏色在系統裡代表什麼。
因此整條關係應該是:
Primitive
green-700
↓
Semantic
success
↓
Component
Badge / Alert
↓
Content
已通過 / 儲存成功
這也是 Design Tokens 開始真正有價值的地方。
今天最後檢查:
aria-hidden="true"
role="alert"
role="alert"
最後執行:
npm run build
npm run lint
npm run check:contrast
確認元件與 Color Tokens 都沒有問題。
今天使用:
feat/status-feedback
分支開發。
依功能拆成:
git commit -m "feat: add status badge component"
git commit -m "feat: add accessible alert component"
git commit -m "feat: add empty state pattern"
最後 Push 並建立 PR:
git push -u origin feat/status-feedback
今天的 CUI 又多了三種元件:
CUI
│
├── Input / Form
│
├── Selection Controls
│
├── Select
│
└── Status & Feedback
│
├── Badge
├── Alert
└── Empty State
而今天最重要的其實不是記住:
variant="success"
而是:
當資訊很重要時,不要讓使用者必須「看得出某個顏色」才能理解它。
顏色可以讓狀態更快被辨識。
Icon 可以讓狀態更容易掃視。
ARIA 可以在適當的時機幫助輔助科技取得資訊。
但它們都不能取代最基本的一件事:
把真正重要的資訊,用清楚的內容表達出來。
下一篇開始處理另一個後台系統超常見的東西:
Table。
當資料開始變成:
姓名 狀態 更新時間
王小明 已通過 2026/10/01
陳小華 待審核 2026/10/01
林小美 已退回 2026/09/30
問題就來了:
看起來排得整整齊齊,就算 Table 了嗎?
下一篇會從真正的 HTML Table semantics 開始,看看:
<table>
<caption>
<thead>
<tbody>
<th>
<td>
到底各自在 Accessibility 裡負責什麼。
Day 18:Table:資料列表不是用一堆 <div> 排整齊就好。