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 コンポーネントの基本的な使い方を示しています。open と onClose という 2 つの Props だけでアクセシブルなダイアログが実装できます。
typescript {/* Panel はダイアログの本体 */}
<Dialog.Panel>
<Dialog.Title>確認</Dialog.Title>
<Dialog.Description>
この操作を実行してもよろしいですか?
</Dialog.Description>
<button onClick={() => setIsOpen(false)}>
キャンセル
</button>
</Dialog.Panel>
</Dialog>
</>
)
}
Dialog コンポーネントは内部で aria-labelledby、aria-describedby、role="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 は、コンポーネントを細かいパーツに分割しています。Root、Trigger、Portal、Overlay、Content など、それぞれが明確な役割を持ちます。
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 UI | Radix UI |
|---|---|---|---|
| 1 | WAI-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 パターンを使うことで、コンポーネントの内部状態(active、selected など)にアクセスできます。この状態に基づいて、動的にスタイルを変更できます。
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>
sideOffset や align などの 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 UI | Radix UI |
|---|---|---|---|
| 1 | Tailwind CSS 親和性 | ◎ | ○ |
| 2 | CSS-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.Group、Tab.List、Tab、Tab.Panels、Tab.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>
value と defaultValue で、制御/非制御コンポーネントの選択ができます。これは 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 UI | Radix UI |
|---|---|---|---|
| 1 | API のシンプルさ | ◎ | ○ |
| 2 | 公式ドキュメント | ◎ | ◎ |
| 3 | サンプルコードの充実度 | ○ | ◎ |
| 4 | TypeScript サポート | ○ | ◎ |
| 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>
ダイアログパネルにも個別のアニメーションを設定できます。scale と opacity を組み合わせることで、滑らかな表示アニメーションを実現します。
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 では、状態管理を内部で行います。開発者は open と onOpenChange で制御することも、自動管理させることもできます。
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 はネストされたメニュー(サブメニュー)を Sub、SubTrigger、SubContent で簡単に実装できます。これは 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 UI | Radix UI |
|---|---|---|---|
| 1 | Dialog / Modal | ○ | ○ |
| 2 | Dropdown Menu | ○(Menu) | ○ |
| 3 | Popover | ○ | ○ |
| 4 | Tabs | ○ | ○ |
| 5 | Disclosure / Accordion | ○(Disclosure) | ○(Accordion) |
| 6 | Listbox / Select | ○ | ○ |
| 7 | Combobox / Autocomplete | ○ | ○(未公開) |
| 8 | Radio Group | ○ | ○ |
| 9 | Switch / Toggle | ○ | ○ |
| 10 | Tooltip | × | ○ |
| 11 | Context Menu | × | ○ |
| 12 | Slider | × | ○ |
| 13 | Progress | × | ○ |
| 14 | Checkbox | × | ○ |
| 15 | Separator | × | ○ |
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 を構築できます。プロジェクトの要件、チームのスキルセット、既存の技術スタックを総合的に判断して、最適な選択をしてください。
関連リンク
articleHeadless UI と Radix UI を徹底比較:アクセシビリティ・拡張性・学習コスト
articleHeadless UI の Dialog がスクロール固定されない/背景が動く時の対処法
articleHeadless UI とは?コンポーネントの中身だけで柔軟な UI を実現する仕組みを徹底解説
article【最新比較】Gemini 3 Pro vs GPT-5.1 Codex-Max: 開発者が本当に使うべきAIはどっち?
articleHeadless UI と Radix UI を徹底比較:アクセシビリティ・拡張性・学習コスト
articleZod で URL・検索パラメータを型安全に扱う手順(SPA/SSR 共通)
articleApollo Link レシピ集:Retry/Error/Batch/Context の組み合わせ 12 例
articleYarn を Classic から Berry に移行する手順:yarn set version の正しい使い方
articleMongoDB vs PostgreSQL 実測比較:JSONB/集計/インデックスの性能と DX
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来