無樣式元件庫:Headless UI、Radix、React Aria | TailwindCSS 完整教學

2026/08/30
無樣式元件庫: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 開啟時把焦點鎖在內部)。
  • 無障礙層:正確的 rolearia-* 屬性、螢幕閱讀器可讀的語意結構。

傳統(有樣式)元件庫把這三層全包了——你得到成品,但樣式層很難改。headless 元件庫則刻意只保留行為層與無障礙層,把樣式層整個拿掉,交給你用 Tailwind 補上:

傳統(有樣式)元件庫              headless 元件庫 + TailwindCSS
────────────────────           ──────────────────────────────
┌──────────────────┐           ┌──────────────────┐  你控制
│ 樣式 (難覆蓋)     │           │ 樣式 (Tailwind)   │  ← 完全自由
│ 行為 (開關/焦點)  │           ├──────────────────┤
│ ARIA (role/aria)  │           │ 行為 (開關/焦點)  │  庫提供
└──────────────────┘           │ ARIA (role/aria)  │  ← 不重造輪子
                               └──────────────────┘

這個分工正是 headless + Tailwind 被稱為黃金組合的原因:你最不想自己寫、又最容易寫錯的(無障礙與鍵盤互動)由專業的庫負責,你最想完全掌控的(視覺)由 Tailwind 的 utility 精準控制。兩者分屬不同層次、互不打架——庫不會塞給你任何要對抗的預設樣式,Tailwind 也不必去猜元件的行為狀態。

三大方案比較對照表

React 生態裡最主流的三套 headless 方案是 Headless UIRadix UIReact Aria。先用一張表建立全景,再逐一拆解:

方案維護方框架元件數量無障礙品質API 風格Tailwind 整合學習曲線
Headless UITailwind Labs(官方)React / Vue約 10 個優秀元件原生設計、最緊密
Radix UIRadix(WorkOS)React50+ 原語優秀元件(組合式)透過 data-* 屬性
React AriaAdobeReact30+ 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(可搜尋下拉)、PopoverSwitchTabDisclosureRadioGroupTransition(過渡動畫)——大約十個,涵蓋日常最需要的互動。優點:上手最快、與 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-highlighteddata-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 制定的無障礙規範,定義各種互動元件應具備的 rolearia-* 屬性。
  • 原語 / 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-activedescendantrole="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-statedata-highlighteddata-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》 就要帶你認識這個近年最紅、重新定義了「元件庫」形態的方案。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →