Radix UI: React Headless UI 元件庫

在現代 React 開發中,出現了一種新的趨勢:Headless UI(無頭元件)。這類元件庫提供完整的互動邏輯和無障礙功能 (Accessibility / a11y),但完全不提供樣式

Radix UI 就是其中的佼佼者。它讓你不用重新造輪子去處理複雜的像是 Dialog 焦點鎖定、Dropdown 鍵盤導航等問題,同時又給你 100% 的樣式控制權(這讓它成為 Tailwind CSS 的最佳拍擋)。

安裝

Radix UI 是模組化的,你可以只安裝你需要的元件。

npm install @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-switch

核心優勢 (Why Radix?)

Radix 的元件通常由多個部分 (Parts) 組成,你需要依照結構組合它們。這種模式雖然比傳統元件庫繁瑣一點,但帶來了極大的靈活性。

常用元件範例 (Common Primitives)

除了 Dialog,以下是幾個在開發中最常使用的元件:

Popover (氣泡對話框)

不同於 Dialog,Popover 是「非模態 (Non-modal)」的,使用者可以點擊外部關閉它,且背景不會變黑。常用於過濾器選單、更多選項。

import * as Popover from '@radix-ui/react-popover';

<Popover.Root>
  <Popover.Trigger>更換顏色</Popover.Trigger>
  <Popover.Portal>
    <Popover.Content className="bg-white p-4 shadow-lg rounded">
      選擇你喜歡的顏色...
      <Popover.Arrow className="fill-white" />
    </Popover.Content>
  </Popover.Portal>
</Popover.Root>;

Tooltip (提示文字)

當滑鼠懸停時顯示的輔助說明。

import * as Tooltip from '@radix-ui/react-tooltip';

<Tooltip.Provider>
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <button className="icon-btn">+</button>
    </Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content className="bg-black text-white px-2 py-1 text-sm rounded">
        新增項目
        <Tooltip.Arrow className="fill-black" />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>;

Accordion (手風琴)

常見的收合/展開列表。

import * as Accordion from '@radix-ui/react-accordion';

<Accordion.Root type="single" collapsible>
  <Accordion.Item value="item-1">
    <Accordion.Header>
      <Accordion.Trigger>常見問題 1</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Content>這是問題 1 的答案內容。</Accordion.Content>
  </Accordion.Item>
</Accordion.Root>;

Dialog (對話框) 範例

一個無障礙的 Modal 其實非常難寫(要處理 Esc 關閉、點擊遮罩關閉、Focus Trap、移除 Scrollbar 等)。Radix 幫你處理了所有這些。

import * as Dialog from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import './dialog.css'; // 這裡定義你的樣式 (或使用 Tailwind)

export default () => (
  <Dialog.Root>
    <Dialog.Trigger asChild>
      <button className="Button violet">編輯個人資料</button>
    </Dialog.Trigger>

    <Dialog.Portal>
      {/* 遮罩層 */}
      <Dialog.Overlay className="DialogOverlay" />

      {/* 內容層 */}
      <Dialog.Content className="DialogContent">
        <Dialog.Title className="DialogTitle">編輯個人資料</Dialog.Title>
        <Dialog.Description className="DialogDescription">
          在這裡修改你的資料,完成後點擊儲存。
        </Dialog.Description>

        <fieldset className="Fieldset">
          <label className="Label" htmlFor="name">
            姓名
          </label>
          <input className="Input" id="name" defaultValue="Mike Lee" />
        </fieldset>

        <div style={{ display: 'flex', marginTop: 25, justifyContent: 'flex-end' }}>
          <Dialog.Close asChild>
            <button className="Button green">儲存變更</button>
          </Dialog.Close>
        </div>

        <Dialog.Close asChild>
          <button className="IconButton" aria-label="Close">
            <Cross2Icon />
          </button>
        </Dialog.Close>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>
);

重點屬性:

  • Trigger: 打開 Dialog 的按鈕。
  • Portal: 將內容渲染到 body 結尾(避免 z-index 問題)。
  • Overlay: 背景遮罩。
  • Content: 主要內容區域。
  • asChild: Radix 的特殊設計。如果設為 true,它不會渲染自己的 DOM 節點,而是將功能與 props 傳遞給它的子元件。

搭配 Tailwind CSS

Radix 和 Tailwind 簡直是天作之合。因為 Radix 沒有樣式,你可以直接把 Tailwind classes 加上去。

import * as Switch from '@radix-ui/react-switch';

const Toggle = () => (
  <div className="flex items-center">
    <label className="text-white text-[15px] leading-none pr-[15px]" htmlFor="airplane-mode">
      飛航模式
    </label>
    <Switch.Root
      className="w-[42px] h-[25px] bg-blackA9 rounded-full relative shadow-[0_2px_10px] shadow-blackA7 focus:shadow-[0_0_0_2px] focus:shadow-black data-[state=checked]:bg-black outline-none cursor-default"
      id="airplane-mode"
      style={{ '-webkit-tap-highlight-color': 'rgba(0, 0, 0, 0)' }}
    >
      <Switch.Thumb className="block w-[21px] h-[21px] bg-white rounded-full shadow-[0_2px_2px] shadow-blackA7 transition-transform duration-100 translate-x-0.5 will-change-transform data-[state=checked]:translate-x-[19px]" />
    </Switch.Root>
  </div>
);

注意 Radix 提供了 data-state 屬性(如 data-state="checked"),你可以利用 Tailwind 的 data- 前綴來定義不同狀態的樣式:

data-[state=checked]:bg-black

動畫進場與離場 (Animation)

Radix 會自動在元件上根據目前狀態加上 data-state="open"data-state="closed" 屬性。

我們可以利用 Tailwind 的 tailwindcss-animate 插件提供的 class,或是自定義 CSS Keyframes 來製作進出場動畫。

使用 Tailwind (需設定 keyframes):

/* 自定義 Keyframes */
@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

.DialogContent[data-state='open'] {
  animation: fadeIn 200ms ease-out;
}
.DialogContent[data-state='closed'] {
  animation: fadeOut 200ms ease-in;
}

或者使用 Tailwind 語法:

<Dialog.Content
  className="... data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>

無障礙設計 (Accessibility Deep Dive)

Radix 最強大的地方在於它默默幫你處理了無數個無障礙細節:

  1. Focus Management (焦點管理)
    • Focus Trap: 打開 Dialog 時,焦點會被「鎖」在對話框內,Tab 鍵出不去。
    • Focus Restoration: 關閉 Dialog 後,焦點會自動回到原本觸發按鈕上。
  2. Screen Reader Support (螢幕閱讀器)
    • 自動補上 aria-expanded, aria-controls, aria-labelledby 等屬性。
    • 使用 VisuallyHidden 元件來提供僅螢幕閱讀器可見的描述。
  3. Keyboard Navigation (鍵盤導航)
    • Dropdown Menu 支援上下鍵選擇、Esc 關閉、Home/End 跳轉。

服務端渲染 (SSR)

如果你在 Next.js (App Router) 中使用 Radix UI,因為 Radix 依賴大量的 useState, useEffect 來處理互動邏輯,所以所有的 Radix 元件必須在 Client Component 中使用。

記得在檔案最上方加上:

'use client';

import * as Dialog from '@radix-ui/react-dialog';
// ...

常見元件庫 (shadcn/ui)

你也許聽過 shadcn/ui,它目前非常熱門。其實 shadcn/ui 不是一個元件庫(你不能 npm install 它),它是一套設計好的程式碼片段集合

它的底層正是使用 Radix UI 處理互動,並使用 Tailwind CSS 處理樣式。這證明了 Radix + Tailwind 這種「Headless + Utility」模式是目前 React UI 開發的主流趨勢。

總結

使用 Radix UI,你可以獲得:

  1. 堅實的無障礙基礎 (WAI-ARIA compliance)。
  2. 完整的鍵盤導航支援。
  3. 絕對的樣式自由。

它是構建高品質 Design System 的最佳基石。