iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
自我挑戰組

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

Day 17: Badge、Alert、Empty State:狀態不能只靠顏色表達

  • 分享至 

  • xImage
  •  

前幾天,我們花了不少時間處理表單。

從 Input、Field、Textarea,一路做到 Checkbox、Radio、Switch、Select,最後在 Day 16 把這些元件組成一套完整的 Form Pattern。

但一個系統不會只有「輸入」。

使用者送出資料後,我們還需要告訴他:

  • 申請目前是什麼狀態?
  • 資料有沒有儲存成功?
  • 有沒有需要注意的事情?
  • 為什麼這個列表什麼都沒有?

這些都屬於另一類很常見的 UI:

Status & Feedback

所以今天先暫時離開表單,來做三種常見的狀態元件:

  • Badge
  • Alert
  • Empty State

同時也要處理一個很重要的 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。


1. 為什麼狀態不能只有顏色?

假設一個申請系統裡有三筆資料:

● 王小明
● 陳小華
● 林小美

並且規定:

綠色 = 已通過
黃色 = 待審核
紅色 = 已退回

對可以正常辨識這些顏色的人來說,也許一眼就看懂了。

但是如果使用者無法辨識這些顏色,畫面可能只剩:

● 王小明
● 陳小華
● 林小美

原本最重要的「狀態」資訊就消失了。

因此,比較好的方式是:

王小明  [ 已通過 ]
陳小華  [ 待審核 ]
林小美  [ 已退回 ]

即使把所有顏色拿掉:

[ 已通過 ]
[ 待審核 ]
[ 已退回 ]

資訊依然存在。

也就是:

文字 → 主要資訊
顏色 → 額外的視覺提示

而不是:

顏色 → 唯一的資訊來源

這也是今天設計 Badge 和 Alert 時最重要的原則。


2. Badge:最小型的狀態資訊

先加入 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,而不只是「某一種綠色」。


3. 不要在 Badge 裡重新發明綠色

做到這裡時遇到了一個問題。

我原本在 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 決定。


4. CUI Badge

最後的 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。


5. Badge 不一定需要很複雜

這次 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。


6. Alert:狀態變成一整段訊息

Badge 適合很短的狀態:

[ 已通過 ]

但有些情況需要更完整的訊息:

⚠ 資料尚未完成

還有 3 個必填欄位需要填寫。

這時就適合使用 Alert。

加入:

npx shadcn@latest add alert

CUI 最後定義四種:

info
success
warning
error

例如:

<Alert variant="warning">
  <AlertTitle>
    資料尚未完成
  </AlertTitle>

  <AlertDescription>
    還有 3 個必填欄位需要填寫。
  </AlertDescription>
</Alert>

7. Alert 一樣使用 Semantic Tokens

這次 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 就正式串起來了。


8. role="alert" 不是 Alert 元件的必備屬性

這次看 shadcn/ui 原始 Alert 時,我注意到它直接寫:

<div role="alert">

但 CUI 最後把這個預設拿掉了。

原因是:

長得像 Alert,不代表一定需要 ARIA alert role。

例如頁面一開始就有:

⚠ 注意

申請資料送出後將無法修改。

這是頁面原本就存在的重要資訊。

它可以只是:

<Alert variant="warning">

不一定需要:

role="alert"

但是另一種情況:

使用者按下:

[ 儲存 ]

接著畫面突然出現:

儲存失敗

無法儲存資料,請稍後再試。

這種操作後動態出現的重要訊息,才可能適合:

<Alert
  variant="error"
  role="alert"
>

因此 CUI 沒有把 role="alert" 寫死。

而是讓使用者依情境決定:

Alert visual style
        ≠
ARIA alert semantics

這點我覺得非常重要。

Accessibility 並不是看到一個元件叫 Alert,就把所有 ARIA 全塞進去。


9. Alert Icon 是輔助,不是資訊本身

接著替 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。


10. CUI Alert Contract

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 就能逐漸共用同一套視覺規則。


11. Empty State:什麼都沒有,也是一種狀態

最後來做 Empty State。

這次沒有使用 shadcn/ui,而是自己從零建立一個 Pattern。

不過在開始之前,先區分三種很容易混在一起的狀態:

Empty
→ 原本就沒有資料

No Results
→ 有資料,但搜尋 / 篩選後沒有符合結果

Error
→ 應該有資料,但載入失敗

例如:

Empty

尚無收藏項目

你目前還沒有收藏任何內容。

[ 瀏覽內容 ]

No Results

找不到符合「React」的結果

請嘗試其他關鍵字。

[ 清除搜尋 ]

Error

無法載入收藏內容

請稍後再試。

[ 重新載入 ]

雖然畫面看起來都是「沒有東西」,但原因與使用者下一步完全不同。

今天先處理真正的 Empty State。

Loading / Empty / Error 的完整 Data List Pattern,後面還會再處理。


12. 自己建立 Empty State 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。


13. Empty State 需要 ARIA 嗎?

通常不需要。

如果它只是正常的頁面內容:

<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 不是越多越好。


14. Action 也不是 Empty State 的必填欄位

有些 Empty State 有很清楚的下一步:

尚無收藏項目

[ 瀏覽內容 ]

但有些沒有。

例如:

尚無歷史紀錄

完成操作後,相關紀錄將顯示於此。

這種情況其實沒有必要硬塞一顆:

[ 知道了 ]

因此:

<EmptyStateAction>

在 CUI 裡是 optional。

Pattern 應該提供合理的組合方式,而不是要求每個畫面長得完全一樣。


15. 今天三個元件,其實在解決三種不同問題

完成後重新整理:

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 解決。


16. 今天真正完成的是 Status Language

回頭看今天的程式碼,好像只是新增:

Badge
Alert
Empty State

但我覺得更重要的是,我們開始替 CUI 建立一套一致的「狀態語言」。

例如:

success
warning
error

不是:

green
yellow
red

因為:

green

只描述「它長什麼顏色」。

而:

success

描述的是:

這個顏色在系統裡代表什麼。

因此整條關係應該是:

Primitive
green-700
    ↓
Semantic
success
    ↓
Component
Badge / Alert
    ↓
Content
已通過 / 儲存成功

這也是 Design Tokens 開始真正有價值的地方。


17. Accessibility Check

今天最後檢查:

Badge

  • 狀態有文字內容
  • 不只依賴顏色區分
  • 非互動 Badge 不需要額外 ARIA
  • 使用 Semantic Status Tokens

Alert

  • 顏色不是唯一狀態提示
  • Icon 為裝飾時使用 aria-hidden="true"
  • Static Alert 不自動加入 role="alert"
  • Dynamic important feedback 可依情境使用 role="alert"
  • Title / Description 本身具有清楚文字內容

Empty State

  • 有清楚的 Title
  • Description 解釋目前狀態
  • Icon 為裝飾時隱藏於 Accessibility Tree
  • Action 只有在真的有下一步時才加入
  • 不加入沒有必要的 ARIA

最後執行:

npm run build
npm run lint
npm run check:contrast

確認元件與 Color Tokens 都沒有問題。


Git

今天使用:

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

Day 17 完成

今天的 CUI 又多了三種元件:

CUI
│
├── Input / Form
│
├── Selection Controls
│
├── Select
│
└── Status & Feedback
    │
    ├── Badge
    ├── Alert
    └── Empty State

而今天最重要的其實不是記住:

variant="success"

而是:

當資訊很重要時,不要讓使用者必須「看得出某個顏色」才能理解它。

顏色可以讓狀態更快被辨識。

Icon 可以讓狀態更容易掃視。

ARIA 可以在適當的時機幫助輔助科技取得資訊。

但它們都不能取代最基本的一件事:

把真正重要的資訊,用清楚的內容表達出來。


Next:Day 18

下一篇開始處理另一個後台系統超常見的東西:

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> 排整齊就好。


上一篇
Day 16: 把表單元件組起來:第一個完整 Form Pattern
下一篇
Day 18: Table:資料列表不是用一堆 `<div>` 排整齊就好
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言