T-CREATOR

Headless UI と Radix UI を徹底比較:アクセシビリティ・拡張性・学習コスト

Headless UI と Radix UI を徹底比較:アクセシビリティ・拡張性・学習コスト

モダンな Web 開発において、アクセシビリティとスタイルの柔軟性を両立させることは、もはや避けて通れない課題となっています。そんな中、注目を集めているのが Headless UI と Radix UI という 2 つのライブラリです。どちらも「ヘッドレス UI コンポーネント」という同じコンセプトを掲げながらも、設計思想や提供する機能には明確な違いがあります。

今回は、実際の開発現場で選択に迷う方のために、アクセシビリティ対応、拡張性、学習コストという 3 つの軸で両者を徹底的に比較していきます。この記事を読めば、プロジェクトに最適なライブラリを自信を持って選べるようになるでしょう。

背景

ヘッドレス UI コンポーネントの登場

従来の UI ライブラリ(Material-UI や Ant Design など)は、デザインとロジックが密結合しており、カスタマイズに限界がありました。一方で、完全にゼロから実装するとアクセシビリティ対応や状態管理が複雑になります。

この問題を解決するために登場したのが「ヘッドレス UI コンポーネント」という概念です。スタイルを一切持たず、機能とアクセシビリティだけを提供することで、デザインの自由度と品質の両立を実現しました。

以下の図は、従来型 UI ライブラリとヘッドレス UI ライブラリの違いを示しています。

mermaidflowchart TB
    subgraph traditional["従来型 UI ライブラリ"]
        td["デザイン + ロジック<br/>(密結合)"]
        td -->|カスタマイズ困難| tlimit["拡張性の限界"]
    end

    subgraph headless["ヘッドレス UI ライブラリ"]
        hl["ロジックのみ提供"]
        hl -->|開発者が自由に| hdesign["デザイン適用"]
        hdesign -->|柔軟性| hfree["高い拡張性"]
    end

    traditional -.->|進化| headless

図で理解できる要点:

  • 従来型はデザインとロジックが一体化
  • ヘッドレス型はロジックのみを提供し、デザインは分離
  • 分離により高い柔軟性とカスタマイズ性を実現

Headless UI の誕生

Headless UI は、Tailwind CSS の開発チームが 2020 年に公開したライブラリです。Tailwind CSS との親和性が高く、ユーティリティファーストな設計思想を持っています。

React と Vue.js をサポートし、シンプルで直感的な API が特徴です。Tailwind Labs が開発・保守しているため、Tailwind エコシステムとの統合がスムーズに行えます。

Radix UI の誕生

Radix UI は、Modulz 社(現 WorkOS)が開発した、より包括的なヘッドレス UI ライブラリです。2021 年頃から本格的に注目を集め始めました。

WAI-ARIA 標準への厳密な準拠と、きめ細かい制御が可能な API 設計が大きな特徴です。shadcn/ui など、多くの UI ライブラリの基盤として採用されています。

課題

選択時の判断基準が不明確

両者とも「ヘッドレス UI」という同じカテゴリに属するため、初見では違いが分かりにくいという課題があります。公式ドキュメントを読んでも、実際の開発でどちらが適しているかは判断が難しいでしょう。

特に以下のような疑問を持つ開発者が多く見られます。

#疑問内容
1アクセシビリティ対応にどの程度の差があるのか
2カスタマイズ性や拡張性はどちらが優れているか
3学習コストやドキュメントの充実度は
4既存プロジェクトへの導入しやすさ
5将来的なメンテナンス性や互換性

実装方法の違いによる混乱

両ライブラリは API 設計が大きく異なります。Headless UI はシンプルさを重視した設計、Radix UI は詳細な制御を可能にする設計です。

この違いを理解せずに選択すると、開発途中で「思っていたのと違う」という事態になりかねません。以下の図は、両者の設計思想の違いを示しています。

mermaidflowchart LR
    choice["ライブラリ選択"]

    choice -->|シンプル重視| headlessui["Headless UI"]
    choice -->|詳細制御重視| radixui["Radix UI"]

    headlessui -->|特徴| h1["少ない Props"]
    headlessui -->|特徴| h2["直感的 API"]
    headlessui -->|特徴| h3["Tailwind 親和性"]

    radixui -->|特徴| r1["多数の Props"]
    radixui -->|特徴| r2["きめ細かい制御"]
    radixui -->|特徴| r3["WAI-ARIA 厳密準拠"]

図で理解できる要点:

  • Headless UI はシンプルさ優先、Radix UI は制御の細かさ優先
  • それぞれ異なる設計思想に基づく
  • プロジェクトの要件に合わせた選択が重要

コンポーネント数の差異

提供されるコンポーネントの種類や数も両者で異なります。Radix UI はより多くのプリミティブコンポーネントを提供する一方、Headless UI は厳選されたコンポーネントセットを提供します。

必要なコンポーネントが提供されているかどうかも、選択の重要な判断材料となるでしょう。

解決策

アクセシビリティ対応の比較

両ライブラリともアクセシビリティを重視していますが、アプローチに違いがあります。

Headless UI のアクセシビリティ

Headless UI は、基本的なアクセシビリティ要件を満たすように設計されています。キーボード操作、フォーカス管理、スクリーンリーダー対応など、必要最低限以上の機能を提供します。

typescriptimport { Dialog } from '@headlessui/react'
import { useState } from 'react'

function SimpleDialog() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        ダイアログを開く
      </button>

      {/* Dialog コンポーネントは自動的にアクセシビリティ属性を付与 */}
      <Dialog open={isOpen} onClose={() => setIsOpen(false)}>

上記のコードは Headless UI の Dialog コンポーネントの基本的な使い方を示しています。openonClose という 2 つの Props だけでアクセシブルなダイアログが実装できます。

typescript        {/* Panel はダイアログの本体 */}
        <Dialog.Panel>
          <Dialog.Title>確認</Dialog.Title>
          <Dialog.Description>
            この操作を実行してもよろしいですか?
          </Dialog.Description>

          <button onClick={() => setIsOpen(false)}>
            キャンセル
          </button>
        </Dialog.Panel>
      </Dialog>
    </>
  )
}

Dialog コンポーネントは内部で aria-labelledbyaria-describedbyrole="dialog" などの属性を自動的に設定します。開発者は Props を渡すだけで、WAI-ARIA に準拠したダイアログが完成します。

Radix UI のアクセシビリティ

Radix UI は WAI-ARIA 1.2 標準への厳密な準拠を掲げており、より詳細なアクセシビリティ制御が可能です。

typescriptimport * as Dialog from '@radix-ui/react-dialog'

function DetailedDialog() {
  return (
    <Dialog.Root>
      {/* Trigger はダイアログを開くボタン */}
      <Dialog.Trigger asChild>
        <button>ダイアログを開く</button>
      </Dialog.Trigger>

      {/* Portal で DOM の別の場所にレンダリング */}
      <Dialog.Portal>

Radix UI は、コンポーネントを細かいパーツに分割しています。RootTriggerPortalOverlayContent など、それぞれが明確な役割を持ちます。

typescript        {/* Overlay は背景のオーバーレイ */}
        <Dialog.Overlay className="overlay" />

        {/* Content はダイアログの内容 */}
        <Dialog.Content className="content">
          <Dialog.Title>確認</Dialog.Title>
          <Dialog.Description>
            この操作を実行してもよろしいですか?
          </Dialog.Description>

          <Dialog.Close asChild>
            <button>キャンセル</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

各パーツを個別に制御できるため、アニメーションやレイアウトのカスタマイズが容易です。asChild prop を使うことで、既存の要素に機能を注入できます。

アクセシビリティ機能の比較表

#機能Headless UIRadix UI
1WAI-ARIA 準拠○(基本的)◎(厳密)
2キーボード操作
3フォーカス管理
4スクリーンリーダー対応
5カスタム属性の制御

拡張性の比較

拡張性の面では、両者のアプローチが大きく異なります。

Headless UI の拡張性

Headless UI は、Tailwind CSS との組み合わせを想定した設計になっています。スタイリングは主に className で行います。

typescriptimport { Menu } from '@headlessui/react'

function StyledMenu() {
  return (
    <Menu>
      {/* Menu.Button にスタイルを直接適用 */}
      <Menu.Button className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
        メニューを開く
      </Menu.Button>

      {/* Menu.Items にもスタイルを適用 */}
      <Menu.Items className="absolute mt-2 w-56 bg-white rounded-md shadow-lg">

Tailwind CSS のユーティリティクラスを使って、直感的にスタイリングできます。className に文字列を渡すだけで、見た目をカスタマイズ可能です。

typescript<Menu.Item>
  {/* render prop パターンで active 状態を取得 */}
  {({ active }) => (
    <a
      className={`${
        active ? 'bg-blue-500 text-white' : 'text-gray-900'
      } block px-4 py-2 text-sm`}
      href='/account'
    >
      アカウント設定
    </a>
  )}
</Menu.Item>

render prop パターンを使うことで、コンポーネントの内部状態(activeselected など)にアクセスできます。この状態に基づいて、動的にスタイルを変更できます。

typescript        <Menu.Item>
          {({ active }) => (
            <a
              className={`${
                active ? 'bg-blue-500 text-white' : 'text-gray-900'
              } block px-4 py-2 text-sm`}
              href="/logout"
            >
              ログアウト
            </a>
          )}
        </Menu.Item>
      </Menu.Items>
    </Menu>
  )
}

Radix UI の拡張性

Radix UI は、CSS-in-JS、CSS Modules、Tailwind CSS など、あらゆるスタイリング手法に対応しています。

typescriptimport * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import './styles.css' // CSS Modules や通常の CSS も使用可能

function FlexibleMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className="trigger-button">
          メニューを開く
        </button>
      </DropdownMenu.Trigger>

asChild prop により、Radix のコンポーネントを既存の要素にマージできます。これにより、既存のコンポーネントライブラリとの統合が容易になります。

typescript      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="content"
          sideOffset={5}
          align="start"
        >
          {/* Arrow で矢印を追加 */}
          <DropdownMenu.Arrow className="arrow" />

          <DropdownMenu.Item className="item">
            アカウント設定
          </DropdownMenu.Item>

sideOffsetalign などの Props で、詳細な配置制御が可能です。Arrow コンポーネントで視覚的な矢印も簡単に追加できます。

typescript          <DropdownMenu.Separator className="separator" />

          <DropdownMenu.Item
            className="item"
            onSelect={() => console.log('ログアウト')}
          >
            ログアウト
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

Separator で視覚的な区切りを追加したり、onSelect イベントで項目選択時の動作をカスタマイズできます。

拡張性の比較表

#項目Headless UIRadix UI
1Tailwind CSS 親和性
2CSS-in-JS 対応
3スタイリング手法の自由度
4コンポーネント分割の細かさ
5既存コンポーネントとの統合◎(asChild)

学習コストの比較

学習コストは、プロジェクトへの導入を決める重要な要素です。

Headless UI の学習コスト

Headless UI は API がシンプルで、学習コストは比較的低めです。React の基本的な知識があれば、すぐに使い始められます。

以下は、タブコンポーネントの実装例です。

typescriptimport { Tab } from '@headlessui/react'

function SimpleTabs() {
  return (
    <Tab.Group>
      {/* Tab.List はタブボタンのコンテナ */}
      <Tab.List className="flex space-x-1 bg-blue-900/20 p-1 rounded-xl">
        <Tab className={({ selected }) =>
          selected ? 'bg-white shadow' : 'text-blue-100 hover:bg-white/[0.12]'
        }>
          タブ 1
        </Tab>

Tab コンポーネントは selected という状態を render prop として提供します。これにより、選択状態に応じたスタイリングが簡単に実装できます。

typescript        <Tab className={({ selected }) =>
          selected ? 'bg-white shadow' : 'text-blue-100 hover:bg-white/[0.12]'
        }>
          タブ 2
        </Tab>
      </Tab.List>

      {/* Tab.Panels はタブパネルのコンテナ */}
      <Tab.Panels className="mt-2">
        <Tab.Panel>タブ 1 の内容</Tab.Panel>
        <Tab.Panel>タブ 2 の内容</Tab.Panel>
      </Tab.Panels>
    </Tab.Group>
  )
}

Tab.GroupTab.ListTabTab.PanelsTab.Panel という階層構造は直感的です。React の基本的なコンポーネント設計パターンに沿っています。

Radix UI の学習コスト

Radix UI は、より多くの概念と Props を理解する必要があるため、学習コストはやや高めです。ただし、その分きめ細かい制御が可能になります。

typescriptimport * as Tabs from '@radix-ui/react-tabs'

function DetailedTabs() {
  return (
    <Tabs.Root defaultValue="tab1">
      {/* Tabs.List はタブボタンのコンテナ */}
      <Tabs.List aria-label="タブメニュー">
        <Tabs.Trigger value="tab1">
          タブ 1
        </Tabs.Trigger>
        <Tabs.Trigger value="tab2">
          タブ 2
        </Tabs.Trigger>
      </Tabs.List>

valuedefaultValue で、制御/非制御コンポーネントの選択ができます。これは React の input 要素と同じパターンです。

typescript      {/* 各 Content には対応する value を指定 */}
      <Tabs.Content value="tab1">
        <p>タブ 1 の内容がここに表示されます</p>
      </Tabs.Content>

      <Tabs.Content value="tab2">
        <p>タブ 2 の内容がここに表示されます</p>
      </Tabs.Content>
    </Tabs.Root>
  )
}

value prop で各タブとパネルを紐付けます。この明示的な紐付けにより、タブの順序を自由に変更できます。

学習コストの比較表

#項目Headless UIRadix UI
1API のシンプルさ
2公式ドキュメント
3サンプルコードの充実度
4TypeScript サポート
5学習曲線緩やかやや急

具体例

実践例 1:アクセシブルなモーダルダイアログ

モーダルダイアログは、アクセシビリティ対応が特に重要なコンポーネントです。両ライブラリでの実装を比較してみましょう。

Headless UI での実装

typescriptimport { Dialog, Transition } from '@headlessui/react'
import { Fragment, useState } from 'react'

function HeadlessUIModal() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        モーダルを開く
      </button>

まず、状態管理と開くボタンを定義します。isOpen という真偽値で、モーダルの開閉を制御します。

typescript      {/* Transition でアニメーション制御 */}
      <Transition appear show={isOpen} as={Fragment}>
        <Dialog
          as="div"
          className="relative z-10"
          onClose={() => setIsOpen(false)}
        >
          {/* 背景のオーバーレイ */}
          <Transition.Child
            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 bg-opacity-25" />
          </Transition.Child>

Headless UI の Transition コンポーネントを使うことで、簡単にアニメーションを追加できます。CSS のトランジションクラスを指定するだけです。

typescript          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4">
              <Transition.Child
                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"
              >
                <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 shadow-xl transition-all">
                  <Dialog.Title className="text-lg font-medium">
                    確認
                  </Dialog.Title>

ダイアログパネルにも個別のアニメーションを設定できます。scaleopacity を組み合わせることで、滑らかな表示アニメーションを実現します。

typescript                  <Dialog.Description className="mt-2 text-sm text-gray-500">
                    この操作を実行すると、データが削除されます。よろしいですか?
                  </Dialog.Description>

                  <div className="mt-4 flex gap-2">
                    <button
                      onClick={() => setIsOpen(false)}
                      className="px-4 py-2 bg-gray-200 rounded"
                    >
                      キャンセル
                    </button>
                    <button
                      onClick={() => {
                        // 実行処理
                        setIsOpen(false)
                      }}
                      className="px-4 py-2 bg-red-500 text-white rounded"
                    >
                      削除
                    </button>
                  </div>
                </Dialog.Panel>
              </Transition.Child>
            </div>
          </div>
        </Dialog>
      </Transition>
    </>
  )
}

Headless UI は、フォーカストラップ、ESC キーでの閉じる、背景クリックでの閉じるなどの機能を自動的に提供します。

Radix UI での実装

typescriptimport * as Dialog from '@radix-ui/react-dialog'
import './modal.css' // スタイルは外部 CSS で定義

function RadixUIModal() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="trigger-button">
          モーダルを開く
        </button>
      </Dialog.Trigger>

Radix UI では、状態管理を内部で行います。開発者は openonOpenChange で制御することも、自動管理させることもできます。

typescript      <Dialog.Portal>
        {/* Overlay は背景 */}
        <Dialog.Overlay className="modal-overlay" />

        <Dialog.Content className="modal-content">
          <Dialog.Title className="modal-title">
            確認
          </Dialog.Title>

          <Dialog.Description className="modal-description">
            この操作を実行すると、データが削除されます。よろしいですか?
          </Dialog.Description>

Portal により、モーダルを DOM の別の場所(通常は body の直下)にレンダリングします。これにより、CSS の z-index 問題を回避できます。

typescript          <div className="modal-actions">
            <Dialog.Close asChild>
              <button className="cancel-button">
                キャンセル
              </button>
            </Dialog.Close>

            <Dialog.Close asChild>
              <button
                className="delete-button"
                onClick={() => {
                  // 実行処理
                }}
              >
                削除
              </button>
            </Dialog.Close>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Dialog.Close で囲んだボタンは、自動的にモーダルを閉じる機能を持ちます。asChild により、既存のボタンに機能を注入します。

以下の図は、両ライブラリのモーダル実装の構造を比較したものです。

mermaidflowchart TB
    subgraph headless["Headless UI 構造"]
        h1["Transition(アニメーション)"]
        h2["Dialog(メイン)"]
        h3["Dialog.Panel(内容)"]
        h4["Dialog.Title"]
        h5["Dialog.Description"]

        h1 --> h2
        h2 --> h3
        h3 --> h4
        h3 --> h5
    end

    subgraph radix["Radix UI 構造"]
        r1["Dialog.Root(ルート)"]
        r2["Dialog.Portal(ポータル)"]
        r3["Dialog.Overlay(背景)"]
        r4["Dialog.Content(内容)"]
        r5["Dialog.Title / Description"]

        r1 --> r2
        r2 --> r3
        r2 --> r4
        r4 --> r5
    end

図で理解できる要点:

  • Headless UI はアニメーションをコンポーネントで管理
  • Radix UI は Portal と Overlay を明示的に分離
  • どちらも Title と Description で適切なアクセシビリティ構造を提供

実践例 2:複雑なドロップダウンメニュー

ネストされたメニューやアイコン付きメニューなど、複雑なドロップダウンを実装する場合の違いを見ていきます。

Headless UI での実装

typescriptimport { Menu } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'

function ComplexMenu() {
  return (
    <Menu as="div" className="relative">
      <Menu.Button className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded">
        <span>アクション</span>
        <ChevronDownIcon className="w-5 h-5" />
      </Menu.Button>

Headless UI の as prop により、任意の HTML 要素やコンポーネントとしてレンダリングできます。ここでは div として扱っています。

typescript      <Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
        <div className="py-1">
          <Menu.Item>
            {({ active }) => (
              <button
                className={`${
                  active ? 'bg-gray-100' : ''
                } group flex w-full items-center px-4 py-2 text-sm text-gray-700`}
              >
                編集
              </button>
            )}
          </Menu.Item>

render prop パターンで active 状態を取得し、ホバー時のスタイルを動的に変更します。これはシンプルで直感的なパターンです。

typescript          <Menu.Item disabled>
            {({ active, disabled }) => (
              <button
                className={`${
                  active ? 'bg-gray-100' : ''
                } ${
                  disabled ? 'opacity-50 cursor-not-allowed' : ''
                } group flex w-full items-center px-4 py-2 text-sm text-gray-700`}
              >
                削除(無効)
              </button>
            )}
          </Menu.Item>
        </div>
      </Menu.Items>
    </Menu>
  )
}

disabled prop を指定することで、項目を無効化できます。disabled 状態も render prop で取得可能です。

Radix UI での実装

typescriptimport * as DropdownMenu from '@radix-ui/react-dropdown-menu'

function ComplexDropdown() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className="trigger-button">
          アクション ▼
        </button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="content"
          sideOffset={5}
        >

sideOffset で、トリガーからのオフセット距離をピクセル単位で指定できます。細かい配置調整が可能です。

typescript          <DropdownMenu.Item
            className="item"
            onSelect={() => console.log('編集')}
          >
            編集
          </DropdownMenu.Item>

          <DropdownMenu.Item
            className="item"
            disabled
          >
            削除(無効)
          </DropdownMenu.Item>

          <DropdownMenu.Separator className="separator" />

Separator コンポーネントで視覚的な区切りを追加します。アクセシビリティ的にも適切な role 属性が付与されます。

typescript          {/* サブメニュー */}
          <DropdownMenu.Sub>
            <DropdownMenu.SubTrigger className="subtrigger">
              その他
              <span className="ml-auto"></span>
            </DropdownMenu.SubTrigger>

            <DropdownMenu.Portal>
              <DropdownMenu.SubContent
                className="subcontent"
                sideOffset={2}
                alignOffset={-5}
              >
                <DropdownMenu.Item className="item">
                  エクスポート
                </DropdownMenu.Item>
                <DropdownMenu.Item className="item">
                  共有
                </DropdownMenu.Item>
              </DropdownMenu.SubContent>
            </DropdownMenu.Portal>
          </DropdownMenu.Sub>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

Radix UI はネストされたメニュー(サブメニュー)を SubSubTriggerSubContent で簡単に実装できます。これは Headless UI にはない機能です。

実践例 3:カスタムスタイリングとアニメーション

両ライブラリでのスタイリング方法の違いを、具体的なアニメーション実装で比較します。

Headless UI でのアニメーション

typescriptimport { Popover, Transition } from '@headlessui/react'
import { Fragment } from 'react'

function AnimatedPopover() {
  return (
    <Popover className="relative">
      <Popover.Button className="px-4 py-2 bg-purple-500 text-white rounded">
        ポップオーバーを開く
      </Popover.Button>

      {/* Transition でアニメーション */}
      <Transition
        as={Fragment}
        enter="transition ease-out duration-200"
        enterFrom="opacity-0 translate-y-1"
        enterTo="opacity-100 translate-y-0"
        leave="transition ease-in duration-150"
        leaveFrom="opacity-100 translate-y-0"
        leaveTo="opacity-0 translate-y-1"
      >

Headless UI の Transition は、Tailwind CSS のクラスベースでアニメーションを定義します。CSS トランジションの知識があればすぐに理解できます。

typescript        <Popover.Panel className="absolute z-10 mt-3 w-screen max-w-sm transform px-4">
          <div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
            <div className="relative bg-white p-7">
              <h3 className="text-sm font-medium text-gray-900">
                お知らせ
              </h3>
              <p className="mt-2 text-sm text-gray-500">
                新機能が追加されました
              </p>
            </div>
          </div>
        </Popover.Panel>
      </Transition>
    </Popover>
  )
}

Radix UI でのアニメーション

Radix UI では、CSS または CSS-in-JS でアニメーションを定義します。data-state 属性を活用します。

typescriptimport * as Popover from '@radix-ui/react-popover'
import './popover.css'

function RadixAnimatedPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <button className="trigger">
          ポップオーバーを開く
        </button>
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content
          className="popover-content"
          sideOffset={5}
        >

CSS ファイル側で、以下のようにアニメーションを定義します。

css/* popover.css */
@keyframes slideUpAndFade {
  from {
    opacity: 0;
    transform: translateY(2px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.popover-content[data-state='open'] {
  animation: slideUpAndFade 200ms ease-out;
}

Radix UI は data-state 属性を自動的に付与します。これにより、CSS で状態に応じたスタイリングが可能です。

typescript          <Popover.Arrow className="popover-arrow" />

          <div className="popover-body">
            <h3>お知らせ</h3>
            <p>新機能が追加されました</p>
          </div>

          <Popover.Close
            className="popover-close"
            aria-label="閉じる"
          >
            ×
          </Popover.Close>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  )
}

Arrow コンポーネントで、ポップオーバーの矢印を簡単に追加できます。位置も自動的に調整されます。

提供コンポーネントの比較

両ライブラリが提供するコンポーネントの種類を比較してみましょう。

#コンポーネントHeadless UIRadix UI
1Dialog / Modal
2Dropdown Menu○(Menu)
3Popover
4Tabs
5Disclosure / Accordion○(Disclosure)○(Accordion)
6Listbox / Select
7Combobox / Autocomplete○(未公開)
8Radio Group
9Switch / Toggle
10Tooltip×
11Context Menu×
12Slider×
13Progress×
14Checkbox×
15Separator×

Radix UI の方が、より多くのプリミティブコンポーネントを提供していることが分かります。

まとめ

Headless UI と Radix UI の比較を通じて、それぞれの特徴と適した用途が見えてきました。

Headless UI が適しているケース

以下のような場合は、Headless UI がおすすめです。

  • Tailwind CSS を使用したプロジェクト
  • シンプルな API で素早く実装したい
  • 学習コストを抑えたい
  • 基本的なアクセシビリティ対応で十分
  • 小〜中規模のプロジェクト

Headless UI は、Tailwind CSS エコシステムとの親和性が非常に高く、直感的な API により短期間で生産性を上げられます。公式の Tailwind UI コンポーネントとも統合しやすいでしょう。

Radix UI が適しているケース

以下のような場合は、Radix UI がおすすめです。

  • WAI-ARIA への厳密な準拠が必要
  • きめ細かいカスタマイズが必要
  • 複雑な UI コンポーネントを構築する
  • 既存のデザインシステムに統合したい
  • 大規模プロジェクトや長期運用を想定

Radix UI は、shadcn/ui などの UI ライブラリの基盤としても採用されており、エンタープライズレベルのプロジェクトでも信頼性が高いです。

技術選定のフローチャート

最後に、どちらを選ぶべきかの判断フローを図で示します。

mermaidflowchart TD
    start["UI ライブラリの選択"]

    start --> q1{"Tailwind CSS を<br/>使用している?"}
    q1 -->|はい| q2{"シンプルな API<br/>を優先?"}
    q1 -->|いいえ| q3{"詳細な制御が<br/>必要?"}

    q2 -->|はい| headless_result["Headless UI を推奨"]
    q2 -->|いいえ| q4{"多数のコンポーネント<br/>が必要?"}

    q3 -->|はい| radix_result["Radix UI を推奨"]
    q3 -->|いいえ| q5{"既存デザインシステム<br/>との統合?"}

    q4 -->|はい| radix_result
    q4 -->|いいえ| headless_result

    q5 -->|必要| radix_result
    q5 -->|不要| headless_result

図で理解できる要点:

  • Tailwind CSS 使用の有無が最初の分岐点
  • シンプルさ優先なら Headless UI
  • 詳細制御や多機能性が必要なら Radix UI

最終的な推奨事項

両ライブラリとも優れた選択肢ですが、プロジェクトの性質により適性が異なります。

小規模なプロジェクトや MVP 開発では Headless UI の方が開発速度を上げられるでしょう。一方、大規模なプロダクトや複雑な要件がある場合は、Radix UI の柔軟性が活きてきます。

どちらを選んでも、アクセシビリティと保守性を両立した高品質な UI を構築できます。プロジェクトの要件、チームのスキルセット、既存の技術スタックを総合的に判断して、最適な選択をしてください。

関連リンク