iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
自我挑戰組

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

Day9: Button 加上 Icon:圖示尺寸與 ARIA 誰負責?

  • 分享至 

  • xImage
  •  

前面幾天把 Button 的 Variant、Size、Focus 等基礎規則整理好後,今天來加入另一個 UI 中很常見的元素:Icon

像這樣:

<Button>
  <Search />
  搜尋
</Button>

或只有Icon:

<Button size="icon-md" aria-label="搜尋">
  <Search />
</Button>

看起來只是多放了一張小圖,但開始做 Design System 後,馬上就會遇到兩個問題:

  1. Icon 的尺寸應該由誰決定?
  2. 螢幕閱讀器需要讀到這個 Icon 嗎?
    今天就來替 CUI 定義第一版 Icon 使用規則。

使用 Lucide React

目前 CUI 選擇 Lucide React 作為 Icon Library。
我使用的專案本來就已安裝,所以可以直接使用
若檢查packaage.json沒有找到,可以下npm install lucide-react

專案原本就已經安裝完成,所以可以直接使用:
import { Search, Plus, Trash2 } from "lucide-react"

接著 Icon 就像一般 React Component:

<Search />
<Plus />
<Trash2 />

Lucide 使用 SVG,因此也能直接透過 Tailwind 控制尺寸:

<Search className="size-4" />
<Search className="size-5" />
<Search className="size-6" />

目前 CUI 不另外包一層 Component。
Lucide 已經提供很簡單的 API,在還沒有額外需求以前,再包一層反而只是增加 abstraction。

Icon 尺寸到底誰負責?

Standalone Icon 很單純:

<Search className="size-5" />

但放進 Button 後就不一樣了。
假設每次都要:

<Button size="sm">
  <Search className="size-3.5" />
  搜尋
</Button>

<Button size="lg">
  <Search className="size-5" />
  搜尋
</Button>

代表使用 Button 的人除了記得:
sm / md / lg

還得記得每種 Button 對應哪個 Icon size。
但這其實應該是 Button 本身的設計規則。

所以 CUI 第一版決定:

Standalone Icon 自己決定尺寸;進入 Component 後,由 Component 決定預設尺寸。

讓 Button Size 同時控制 Icon

原本 Button 的 base style 有:
"[&_svg:not([class*='size-'])]:size-4"

代表所有 Button 裡的 SVG 預設都是 16px。

現在把這條規則移到各個 Size:

size: {
  sm:
    "h-7 gap-1 px-2.5 [&_svg:not([class*='size-'])]:size-3.5",

  md:
    "h-8 gap-1.5 px-2.5 [&_svg:not([class*='size-'])]:size-4",

  lg:
    "h-9 gap-1.5 px-2.5 [&_svg:not([class*='size-'])]:size-5",

  "icon-sm":
    "size-7 [&_svg:not([class*='size-'])]:size-3.5",

  "icon-md":
    "size-8 [&_svg:not([class*='size-'])]:size-4",

  "icon-lg":
    "size-9 [&_svg:not([class*='size-'])]:size-5",
}

第一版的對應關係:
Button Size Icon Size
sm 14px
md 16px
lg 20px
icon-sm 14px
icon-md 16px
icon-lg 20px

因此使用時只需要:

<Button size="lg">
 <Search />
 搜尋
</Button>

Icon 就會跟著 Button 一起調整。

還是可以 Override

注意前面的 selector:
[&_svg:not([class*='size-'])]:size-5

其中:
:not([class*='size-'])

代表只有沒有自行設定 size-* 的 SVG 才會套用 Button 的尺寸。

一般情況:

<Button size="lg">
  <Search />
  搜尋
</Button>

真的有特殊需求時:

<Button size="lg">
  <Search className="size-8" />
  搜尋
</Button>

仍然可以自行覆蓋。

也就是:
提供合理的預設值,但保留必要的 escape hatch。

Icon 的無障礙:ARIA 不是加越多越好

尺寸處理完,接著是今天另一個重點:
這個 Icon 到底需不需要被螢幕閱讀器讀到?

不能看到 SVG 就開始塞 aria-label。
先看 Icon 在畫面上扮演什麼角色。
情況一:Icon + 文字
例如:

<Button>
  <Search />
  搜尋
</Button>

真正傳達操作意義的是「搜尋」兩個字。
放大鏡只是視覺輔助,並沒有增加新的資訊。

因此可以把 Icon 視為 decorative:


搜尋

aria-hidden="true" 代表這個 Icon 不需要出現在 Accessibility Tree 中。
輔助科技只需要理解:
搜尋,按鈕

而不是重複處理 Icon。
同樣地:

<Button>
  <Plus aria-hidden="true" />
  新增
</Button>

或:

  <Trash2 aria-hidden="true" />
  刪除
</Button>

Icon 都只是文字的視覺補充。

情況二:Icon-only Button
接著看這個:

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

視覺使用者看到放大鏡,大概知道它是搜尋。
但 Button 本身沒有任何文字。
這時就需要提供 Accessible Name:

<Button
  size="icon-md"
  aria-label="搜尋"
>
  <Search aria-hidden="true" />
</Button>

有一個很重要的差別:
// ✓ label 給真正可以操作的 Button

<Button aria-label="搜尋">
  <Search aria-hidden="true" />
</Button>

而不是:
// ✗ 不應該把 Button 的名稱交給裡面的 Icon


因為真正被操作的是 Button,而不是裡面的 SVG。

Accessible Name 不只有 aria-label

目前 CUI 的文件會優先推薦:

<Button size="icon-md" aria-label="搜尋">
  <Search aria-hidden="true" />
</Button>

因為最簡單。
但 Accessible Name 也可以來自其他地方。

例如 aria-labelledby:
<span id="search-label">搜尋網站</span>

<Button
  size="icon-md"
  aria-labelledby="search-label"
>
  <Search aria-hidden="true" />
</Button>

或者放入視覺隱藏文字:

<Button size="icon-md">
  <Search aria-hidden="true" />
  <span className="sr-only">搜尋</span>
</Button>

Icon Button 一定要寫 aria-label。
而是:
Icon-only Button 必須有 Accessible Name。

aria-label 也不是 Tooltip

還有一個很容易混淆的地方:

<Button
  size="icon-md"
  aria-label="搜尋"
>
  <Search aria-hidden="true" />
</Button>

aria-label 是提供給輔助科技的名稱,不代表滑鼠移上去就會出現「搜尋」。
所以:
aria-labe → Accessible Name

Tooltip → 畫面上的補充說明

兩者解決的是不同問題。
Tooltip 之後做到 Overlay 元件時,再另外處理。

Icon 顏色先不要自己決定

Icon 放進 Button 後,除了尺寸以外,顏色也盡量跟著所在的 Component。
例如:

<Button variant="primary">
  <Search aria-hidden="true" />
  搜尋
</Button>

Primary Button 的文字如果是白色,Icon 就跟著文字顏色。

換成:
<Button variant="outline">
  <Search aria-hidden="true" />
  搜尋
</Button>

也不需要另外指定 Icon color。
因此目前的方向是:
Button
├─ 決定 Size
├─ 決定 Color
└─ Icon 跟隨 Component

如果未來需要特別突出的 Icon,再透過 Accent / Decorative Design Token 處理,而不是在每個 Icon 裡隨意寫死顏色。

CUI Icon Rules v1
今天最後替 Icon 整理出第一版規則:

Sizing
✓ Standalone Icon 可以自己決定尺寸
✓ Component 裡的 Icon 由 Component 決定預設尺寸
✓ Button size 會同步調整 Icon size
✓ 特殊情況仍允許 override

Accessibility
✓ 先判斷 Icon 是否真的具有資訊
✓ Icon + Text 時,Icon 通常是 decorative
✓ Decorative Icon 使用 aria-hidden="true"
✓ Icon-only Button 必須提供 Accessible Name
✓ aria-label 應該放在 Button,而不是 SVG
✓ aria-label / aria-labelledby / sr-only 都可以提供名稱
✓ aria-label 不等於 Tooltip

API
✓ 目前直接使用 Lucide
✓ 暫時不額外建立 Component

Day 9 Done
今天雖然只是替 Button 放了一個 Icon,但真正處理的其實不是「怎麼顯示一張 SVG」。
而是開始定義:
當一個元素進入 Design System 的 Component 後,哪些事情應該由使用者決定,哪些事情應該由元件自己負責?

Icon 不需要每次自己決定尺寸與顏色;Button 已經知道自己是 sm、md 還是 lg,就應該提供合理的預設。
無障礙也是一樣。

ARIA 不是看到 Icon 就全部加上去,而是先理解這個 Icon 在畫面中的意義,再決定它應該被讀出來,還是安靜地當一個裝飾。

下一篇 Day 10,會繼續把 Button 最後幾塊拼起來:Shape、Loading 與完整的 Component API,然後終於可以讓這顆研究了好幾天的 Button 畢業了~


上一篇
Day 8: Button 不只是能按:從鍵盤操作開始做無障礙
下一篇
Day10: Button v1 完成:Shape、Loading 與 Component API
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言