無樣式元件庫:Headless UI、Radix、React Aria | TailwindCSS 完整教學
上一篇《元件庫》我們比較了 daisyUI、Flowbite 這些有樣式元件庫——它們直接給你配好色的成品外觀,快,但外觀受制於預設。這一篇要走另一條路:無樣式(headless) 元件庫只借「行為與無障礙邏輯」,視覺百分之百由你用 TailwindCSS 自己刷。我們會拆解為何 headless + Tailwind 是黃金組合,比較 Tailwind 官方的 Headless UI、業界最常見的 Radix UI、以及無障礙最強的 Adobe React Aria,並動手做出 Dropdown 與 Dialog。
前言
所謂無樣式元件庫(Headless / Unstyled Component Library),是一種只提供「互動行為與無障礙邏輯」、卻完全不提供視覺樣式的 UI 元件庫。它負責那些難寫又容易寫錯的部分——開關狀態、鍵盤導覽、焦點管理、正確的 ARIA 角色與屬性——然後把「長什麼樣子」這件事百分之百留給你,用 TailwindCSS 的 utility 自己刷。
先用一個生活化的類比建立心智模型。用 headless 元件庫就像買一具「無漆的實木家具骨架」:榫接結構、抽屜滑軌、鉸鏈這些「會動、要順、要耐用」的機構,廠商已經用專業工法做到位(對應無障礙與互動邏輯);但表面要上什麼漆、什麼顏色、什麼質感,完全由你決定(對應 Tailwind 樣式)。相對地,上一篇的有樣式元件庫就像「已經上好漆、配好色的成品家具」——搬回家就能用,但要改成你獨有的風格就得對抗它的既定外觀。headless 讓你同時擁有「專業級的機構」與「完全自由的外觀」。
本系列以 TailwindCSS v4 為預設版本。本篇你將學到:
- headless 的核心分工:為什麼「行為/無障礙交給庫、樣式交給 Tailwind」是黃金組合
- 三大主流方案的定位與取捨:Tailwind 官方的 Headless UI、業界最常見的 Radix UI、無障礙最強的 Adobe React Aria
- 實際動手:用 headless 庫打造 Dropdown 與 Dialog,並用 Tailwind(含
data-state)上樣式 - 無障礙(a11y)為何是硬需求:自己重造無障礙輪子的風險,以及何時該選 headless、何時該選有樣式元件庫
本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。
核心概念
headless 到底把什麼「拿掉」了
要理解 headless,最快的方法是看它相對於「傳統元件庫」拿掉了哪一層。一個完整的互動 UI 元件,其實疊了三層:
- 樣式層:顏色、圓角、間距、陰影、hover/focus 的視覺回饋。
- 行為層:開關狀態、鍵盤操作(方向鍵選項、Esc 關閉、Enter 激活)、焦點管理(Modal 開啟時把焦點鎖在內部)。
- 無障礙層:正確的
role、aria-*屬性、螢幕閱讀器可讀的語意結構。
傳統(有樣式)元件庫把這三層全包了——你得到成品,但樣式層很難改。headless 元件庫則刻意只保留行為層與無障礙層,把樣式層整個拿掉,交給你用 Tailwind 補上:
傳統(有樣式)元件庫 headless 元件庫 + TailwindCSS
──────────────────── ──────────────────────────────
┌──────────────────┐ ┌──────────────────┐ 你控制
│ 樣式 (難覆蓋) │ │ 樣式 (Tailwind) │ ← 完全自由
│ 行為 (開關/焦點) │ ├──────────────────┤
│ ARIA (role/aria) │ │ 行為 (開關/焦點) │ 庫提供
└──────────────────┘ │ ARIA (role/aria) │ ← 不重造輪子
└──────────────────┘
這個分工正是 headless + Tailwind 被稱為黃金組合的原因:你最不想自己寫、又最容易寫錯的(無障礙與鍵盤互動)由專業的庫負責,你最想完全掌控的(視覺)由 Tailwind 的 utility 精準控制。兩者分屬不同層次、互不打架——庫不會塞給你任何要對抗的預設樣式,Tailwind 也不必去猜元件的行為狀態。
三大方案比較對照表
React 生態裡最主流的三套 headless 方案是 Headless UI、Radix UI、React Aria。先用一張表建立全景,再逐一拆解:
| 方案 | 維護方 | 框架 | 元件數量 | 無障礙品質 | API 風格 | Tailwind 整合 | 學習曲線 |
|---|---|---|---|---|---|---|---|
| Headless UI | Tailwind Labs(官方) | React / Vue | 約 10 個 | 優秀 | 元件 | 原生設計、最緊密 | 低 |
| Radix UI | Radix(WorkOS) | React | 50+ 原語 | 優秀 | 元件(組合式) | 透過 data-* 屬性 | 中 |
| React Aria | Adobe | React | 30+ hooks/元件 | 最佳 | Hooks 為主 | 需自行組裝 | 高 |
這張表的關鍵是理解每一欄背後的取捨。「維護方」透露了設計哲學:Headless UI 由 Tailwind Labs 親自打造,所以它天生就是為 Tailwind 而生、整合最緊密;Radix UI 專注把「無障礙原語」做到最完整,是 shadcn/ui 的底層基礎;React Aria 由 Adobe 的無障礙團隊維護,無障礙覆蓋面最廣。「API 風格」則決定你怎麼寫:元件式(Headless UI、Radix)你是引入 <Dialog>、<Menu> 這類現成元件;Hooks 式(React Aria)你拿到的是 useButton()、useDialog() 這些 hook,自己組裝 DOM——靈活性最大、但也最需要你懂無障礙細節。
逐一拆解:三套方案的定位與優缺點
Headless UI(Tailwind Labs 官方)。由 TailwindCSS 原作者團隊維護,是與 Tailwind 整合最緊密的 headless 庫,同時支援 React 與 Vue。它提供的元件精簡實用——Dialog(對話框)、Menu(下拉選單)、Listbox(自訂 Select)、Combobox(可搜尋下拉)、Popover、Switch、Tab、Disclosure、RadioGroup、Transition(過渡動畫)——大約十個,涵蓋日常最需要的互動。優點:上手最快、與 Tailwind 心智一致、Transition 元件讓你直接用 Tailwind class 描述進場/離場動畫;缺點:元件數量少,缺 Date Picker、Slider 這類進階元件。適合以 Tailwind 為核心、需求落在常見互動的 React/Vue 專案。
Radix UI(業界最常見的高品質組合)。React 生態最完整的 headless 原語(primitives) 庫,提供 50+ 個元件(Dialog、Dropdown Menu、Select、Tooltip、Popover、Accordion、Checkbox、Slider、Toast…),每個都嚴格遵循 WAI-ARIA 規範。它的兩大特色:一是組合式 API——<DropdownMenu.Root>、<DropdownMenu.Trigger>、<DropdownMenu.Content>、<DropdownMenu.Item> 這樣拆成可獨立上樣式的子元件;二是透過 data-* 屬性暴露狀態(如 data-state="open"、data-highlighted、data-disabled),讓你用 Tailwind 的 data-[...]: 前綴宣告式地上樣式。「Radix + Tailwind」是業界最常見的高品質組合之一,也正是下一篇主角 shadcn/ui 的底層基礎。優點:元件最完整、無障礙紮實、data-* 上樣式優雅;缺點:只支援 React、需逐一安裝元件套件。
React Aria(Adobe,無障礙最強)。Adobe 無障礙團隊維護的 hooks-based 方案,提供 useButton()、useDialog()、useSelect() 這類 hook,而非現成元件。它的無障礙覆蓋面是三者中最全面的——涵蓋最多 ARIA patterns 與邊界情況,並內建多語言(i18n)與 RTL 支援,是 Adobe 自家 UI 庫 React Spectrum 的基礎。優點:無障礙品質最佳、hooks 給你完全掌控 DOM 結構的最大靈活性;缺點:學習曲線最高——你得自己把多個 hook 組裝成元件,較適合有嚴苛無障礙需求(政府、醫療、金融)或需要高度客製 DOM 的大型專案。
關鍵術語
- 無樣式 / Headless / Unstyled:只提供行為與無障礙邏輯、不提供視覺樣式的元件庫。
- 無障礙(Accessibility,a11y):讓所有使用者(含依賴螢幕閱讀器、只用鍵盤操作者)都能使用介面的設計實踐,核心是正確的 ARIA 屬性、鍵盤操作與焦點管理。
- WAI-ARIA:W3C 制定的無障礙規範,定義各種互動元件應具備的
role與aria-*屬性。 - 原語 / Primitive:如 Radix 提供的底層無樣式元件,是組裝設計系統的最小積木。
data-*狀態屬性:headless 庫(尤以 Radix)在元素上設定的狀態標記(如data-state="open"),供 Tailwind 用data-[...]:前綴上樣式。
實作範例
理論看完,動手把最具代表性的兩個互動元件實作一次:先用 Radix UI 做一個透過 data-state 上樣式的 Dropdown Menu,再用 Headless UI 做一個帶過渡動畫的 Dialog。兩者都只借行為,樣式全用 Tailwind。
範例一:Radix UI Dropdown Menu(用 data-state 上樣式)
Radix 的元件要逐一安裝。這裡只需 Dropdown Menu 這個原語:
# 安裝 Radix 的 Dropdown Menu 原語
npm install @radix-ui/react-dropdown-menu
重點在於:行為(開關、鍵盤導覽、焦點、ARIA)全由 Radix 負責,我們只用 Tailwind 描述外觀,並用 data-[...]: 前綴針對狀態上樣式:
// Dropdown.tsx:Radix 管行為、Tailwind 管樣式
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
export function ActionsMenu() {
return (
<DropdownMenu.Root>
{/* Trigger:注意 data-[state=open]:rotate-180 讓箭頭在展開時翻轉 */}
<DropdownMenu.Trigger
className="inline-flex items-center gap-1 rounded-lg border border-gray-300
px-4 py-2 text-sm outline-none
focus-visible:ring-2 focus-visible:ring-teal-500
data-[state=open]:bg-gray-50"
>
操作
<svg className="h-4 w-4 transition-transform data-[state=open]:rotate-180"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 9l-7 7-7-7" />
</svg>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={6}
align="end"
className="min-w-[180px] rounded-xl border border-gray-200 bg-white p-1 shadow-lg
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Item:data-[highlighted] 是鍵盤/滑鼠高亮時 Radix 自動加的屬性 */}
<DropdownMenu.Item
className="flex cursor-pointer select-none items-center rounded-lg px-3 py-2 text-sm
text-gray-700 outline-none
data-[highlighted]:bg-teal-50 data-[highlighted]:text-teal-700"
>
編輯
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer select-none items-center rounded-lg px-3 py-2 text-sm
text-gray-700 outline-none
data-[highlighted]:bg-teal-50 data-[highlighted]:text-teal-700"
>
複製
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-100" />
{/* 停用項:data-[disabled] 讓它自動變淡且不可點 */}
<DropdownMenu.Item
disabled
className="flex select-none items-center rounded-lg px-3 py-2 text-sm text-red-600 outline-none
data-[highlighted]:bg-red-50
data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
刪除(需權限)
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
注意這段程式碼裡我們完全沒有寫任何 useState 去追蹤開關,也沒手動綁鍵盤事件——方向鍵在選項間移動、Esc 關閉、Enter 激活、以及正確的 role="menu"、aria-* 屬性,全由 Radix 處理好了。我們只做一件事:用 data-[state=open]:、data-[highlighted]:、data-[disabled]: 把 Tailwind 樣式綁到 Radix 暴露的狀態上。這就是 headless + Tailwind 的精髓。
範例二:Headless UI Dialog(帶 Tailwind 過渡動畫)
換 Headless UI 做一個 Modal 對話框。Headless UI 的特色是 Transition 元件,能直接用 Tailwind class 描述進場/離場:
# 安裝 Tailwind 官方的 Headless UI(React 版)
npm install @headlessui/react
// ConfirmDialog.tsx:Headless UI 管開關/焦點陷阱、Tailwind 管樣式與動畫
import {
Dialog, DialogPanel, DialogTitle,
Transition, TransitionChild,
} from "@headlessui/react";
import { Fragment, useState } from "react";
export function ConfirmDialog() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-teal-600 px-4 py-2 text-white hover:bg-teal-700
focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2"
>
刪除項目
</button>
<Transition show={open} as={Fragment}>
<Dialog onClose={() => setOpen(false)} className="relative z-50">
{/* 背景遮罩:用 Tailwind class 描述淡入淡出 */}
<TransitionChild
as={Fragment}
enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100"
leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<DialogTitle className="text-lg font-semibold text-gray-900">
確認刪除
</DialogTitle>
<p className="mt-2 text-sm text-gray-500">
此操作無法復原,確定要刪除嗎?
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
取消
</button>
<button
onClick={() => setOpen(false)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
>
確認刪除
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</>
);
}
這個 Dialog 幫你處理了一堆你可能沒想到的無障礙細節:開啟時把焦點鎖進對話框(焦點陷阱)、按 Esc 或點遮罩會關閉、關閉後把焦點還給觸發按鈕、背景內容標記為 aria-hidden、對話框帶正確的 role="dialog" 與 aria-modal。你若自己用 div 手刻,這些幾乎不可能一次做對。而外觀,則完全是你用 Tailwind 決定的。
常見錯誤與最佳實踐
坑一:自己「重造無障礙輪子」的風險
最大的坑,是低估無障礙互動有多難自己做對。很多人第一反應是「一個 Dropdown 而已,我用 useState + 幾個 onClick 就好」,結果做出來的是一個只有滑鼠能用的假元件:
// ❌ 常見的「假」互動元件:能點,但無障礙全缺
function FakeDropdown() {
const [open, setOpen] = useState(false);
return (
<div>
<div onClick={() => setOpen(!open)}>操作</div> {/* 不是 button,鍵盤無法 focus */}
{open && (
<div> {/* 沒有 role、沒有 aria-* */}
<div onClick={...}>編輯</div> {/* 方向鍵不能導覽、Esc 不能關 */}
</div>
)}
</div>
);
}
這段程式碼的問題不只「不夠好」,而是對一部分使用者根本不能用:只靠鍵盤操作的人無法 focus 到選項、無法用方向鍵移動、按 Esc 不會關;螢幕閱讀器讀不出這是一個選單、也讀不出目前選到哪一項。要補齊這些,你得處理焦點管理、鍵盤事件、aria-expanded/aria-activedescendant、role="menu"/role="menuitem"、以及點外面關閉等一長串細節——這正是 WAI-ARIA 規範裡定義的完整互動模式,自己實作極容易漏掉邊界情況。正確做法:把這些交給 Headless UI / Radix / React Aria,它們已經被大量真實專案與輔助科技驗證過。你省下的不只是時間,而是避免做出一個排除部分使用者的產品——無障礙在許多國家與產業(公部門、金融)甚至是法規要求。
坑二:用 useState 追蹤狀態、再手動切 class
第二個常見的反模式,是明明庫已經用 data-* 暴露了狀態,你卻還自己用 state 追蹤一遍再手動組 class:
// ❌ 多此一舉:Radix 已經有 data-state,你又自己記一份
const [isOpen, setIsOpen] = useState(false);
<DropdownMenu.Root onOpenChange={setIsOpen}>
<svg className={isOpen ? "rotate-180" : ""} /> {/* 手動同步,容易不一致 */}
</DropdownMenu.Root>
// ✅ 正確:直接用 data-[state=open]: 讓 Tailwind 綁在庫維護的狀態上
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<svg className="transition-transform data-[state=open]:rotate-180" />
</DropdownMenu.Trigger>
</DropdownMenu.Root>
原則是:狀態的「單一事實來源」應該是庫。Radix 用 data-state、data-highlighted、data-disabled 等屬性把狀態掛在 DOM 上,你用 Tailwind 的 data-[...]: 前綴宣告式地綁樣式即可——不必自己 useState 追一份、也不會有兩份狀態不同步的 bug。(Headless UI 則多用 render props 把狀態當參數傳給你,如 ({ active, selected }) => cn(...),新版同樣支援 data-* 屬性選擇器。)
何時用 headless、何時用有樣式元件庫
headless 很強,但不是所有情境都該用。收斂成一個決策準則:
- 選 headless(+ Tailwind):你要做有強烈品牌識別、外觀完全自訂的正式產品;願意(也需要)自己寫樣式;無障礙是硬需求。→ Headless UI / Radix / React Aria。
- 選有樣式元件庫:你要快——原型、MVP、內部後台系統;外觀「夠好看就行」、不需要獨一無二;不想自己刷樣式。→ 上一篇的 daisyUI / Flowbite / Tailwind Plus。
兩者也能混搭:用有樣式元件庫快速鋪好大部分畫面,對少數需要品牌獨特外觀的關鍵元件改用 headless 精雕。選型的重點永遠是「以你此刻的專案階段與對外觀獨特性的要求,哪一側的取捨最貼合」。
最佳實踐小結
- 別自己重造無障礙輪子:焦點管理、鍵盤導覽、ARIA 屬性交給經過驗證的 headless 庫。
- 狀態的單一事實來源是庫:用
data-[state=...]:、render props 上樣式,別自己useState追一份。 - 保留焦點可見性:用
focus-visible:ring-*給鍵盤使用者清楚的焦點指示,別用outline-none一刀切掉。 - 依需求選庫:Tailwind 整合首選 Headless UI、要元件完整選 Radix、要無障礙最強選 React Aria。
- headless 換自由、有樣式換速度:依專案階段與品牌需求選邊,必要時混搭。
小結
這是 TailwindCSS 完整教學 系列的第三十篇。上一篇 《元件庫》 我們比較了 daisyUI、Flowbite、Preline、Tailwind Plus 這些「有樣式」元件庫——它們給你速度,代價是外觀受制於預設;而這一篇,我們走向光譜的另一端,認識了只借「行為與無障礙邏輯」、把視覺完全交給 Tailwind 的「無樣式(headless)」元件庫。回顧幾個重點:
- headless 的核心分工:庫負責行為與無障礙(開關、鍵盤、焦點、ARIA),你用 Tailwind 負責樣式——這就是 headless + Tailwind 的黃金組合。
- 三大方案:Headless UI(Tailwind Labs 官方、與 Tailwind 整合最緊密、上手最快)、Radix UI(元件最完整、透過
data-*上樣式、業界最常見的高品質組合)、React Aria(Adobe 出品、無障礙最強、hooks 靈活但學習曲線高)。 - 實作要領:用
data-[state=open]:、data-[highlighted]:把 Tailwind 樣式綁在庫維護的狀態上,不必自己追 state。 - 兩大坑:別自己重造無障礙輪子(容易做出排除鍵盤/螢幕閱讀器使用者的假元件)、別用
useState重複追蹤庫已暴露的狀態。
「Radix + Tailwind」是業界最常見的高品質組合——但每次都要自己逐一安裝原語、拼裝子元件、寫一堆 data-* class,有沒有更快的方式?有人把「Radix + Tailwind + cva + cn()」整套打包成可複製貼上、你完全擁有的元件。下一篇 《shadcn/ui》 就要帶你認識這個近年最紅、重新定義了「元件庫」形態的方案。