前面幾天把 Button 的 Variant、Size、Focus 等基礎規則整理好後,今天來加入另一個 UI 中很常見的元素:Icon
像這樣:
<Button>
<Search />
搜尋
</Button>
或只有Icon:
<Button size="icon-md" aria-label="搜尋">
<Search />
</Button>
看起來只是多放了一張小圖,但開始做 Design System 後,馬上就會遇到兩個問題:
目前 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。
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 的 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 一起調整。
注意前面的 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 到底需不需要被螢幕閱讀器讀到?
不能看到 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。
目前 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。
還有一個很容易混淆的地方:
<Button
size="icon-md"
aria-label="搜尋"
>
<Search aria-hidden="true" />
</Button>
aria-label 是提供給輔助科技的名稱,不代表滑鼠移上去就會出現「搜尋」。
所以:
aria-labe → Accessible Name
Tooltip → 畫面上的補充說明
兩者解決的是不同問題。
Tooltip 之後做到 Overlay 元件時,再另外處理。
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 畢業了~