Radix UIの使い方をClaude Codeで覚える:ヘッドレスUIで壊れないモーダルを作る
ヘッドレスUIとは何か、Radix UIでDialog/Dropdown/Tabsを作る手順、shadcnとの違いまで。Claude Codeにアクセシビリティを壊させないコピペ用コード付き。
自作のモーダルが、いちばん事故ります。
僕も昔やりました。見た目はきれいに整ったモーダル。クリックすれば開くし、閉じるボタンも効く。デモでは完璧でした。ところが本番で、キーボードだけ使う人から「Escで閉じない」「Tabを押すとモーダルの裏のボタンにフォーカスが飛んでいく」と報告が来た。スクリーンリーダーでは、開いても何のダイアログか読み上げられない。クリックで動くことと、誰でも操作できることは、まったくの別物だったわけです。
このフォーカスの閉じ込め、Escでの開閉、読み上げ用の名前付け——地味で、しかも一つでも抜けると壊れる。ここを自前で全部やるのが、そもそも間違いでした。
そこで使うのが Radix UI です。今日はこれを、Claude Codeに任せながら覚えていきます。
この記事の要点
- Radix UI(Primitives)はヘッドレスUI。見た目(スタイル)は持たず、振る舞いとアクセシビリティだけを提供する部品集です。
- Dialog・Dropdown Menu・Tabsといった「自作すると壊れやすい部品」の、フォーカス管理・キーボード操作・読み上げを肩代わりしてくれます。
- スタイルは自分で書く。だから既存デザインにそのまま馴染ませられます。CSSでもTailwindでも自由。
- shadcn/uiはRadixを土台に、スタイル付きコードをコピペで配る仕組み。Radixが「素の部品」、shadcnが「味付け済みのコピー元」です。
- Claude Codeには「ゼロから作って」ではなく「Radixを使って、アクセシビリティを壊さず」と頼むと、レビューしやすい差分になります。
ヘッドレスUIとは何か
「ヘッドレス(headless)」は直訳すると「頭がない」。UIの世界では、見た目という“頭”を取り払って、中身の振る舞いだけ残した部品を指します。
レストランに例えると分かりやすいです。普通のUIライブラリは「盛り付け済みの完成料理」を出してきます。おいしいけれど、皿も味付けも向こうの好み。一方ヘッドレスUIは「下ごしらえ済みの食材」を出します。火を通す手間(=面倒なアクセシビリティ処理)は済んでいて、盛り付け(=見た目)はこちらの自由。サイトのデザインに合わせて、自分で皿に乗せられます。
Radix UIの公式ドキュメントは、Primitivesを「スタイルなしで出荷され、見た目を完全にコントロールできる」「WAI-ARIAのデザインパターンに従い、aria属性やrole属性、フォーカス管理、キーボード操作といった実装の難所をこちらで処理する」部品集だと説明しています。つまり、面倒なところは任せて、デザインだけ自分でやる——これがヘッドレスの正体です。
ちなみにWAI-ARIA(ウェイ・アリア)というのは、支援技術にUIの意味を伝えるための仕様のこと。「これはダイアログです」「今このタブが選ばれています」とブラウザの裏側で伝える約束事だと思ってください。
なぜ自作モーダルは壊れるのか
そもそも、モーダルの「中身」って何をしているのか。Radix Dialogが裏で面倒を見ているものを並べると、自作がいかに大変か分かります。
| やること | 自作だと | Radix Dialogだと |
|---|---|---|
開いている間、Tabが外に出ない(フォーカストラップ) | 自分でキー監視を書く | 自動 |
Escで閉じる | キーイベントを毎回書く | 自動 |
| 閉じたら、開いたボタンにフォーカスを戻す | 忘れがち | 自動 |
| 「これはダイアログ」と読み上げさせる | roleやaria-*を手書き | Title/Descriptionで対応 |
| 背景スクロールを止める | bodyを直接いじる | 自動 |
公式ドキュメントによると、Dialogは「モーダル時にフォーカスを自動で内側に閉じ込める」「Escで自動的に閉じる」「TitleとDescriptionでスクリーンリーダーの読み上げを管理する」と明記されています。この一覧のどれか一つを実装し忘れただけで、冒頭の僕のような事故が起きるわけです。
Radixを使えば、この表の「自動」の列を丸ごと借りられます。残るのは見た目だけ。だから壊れにくい。
Claude Codeとヘッドレスは相性がいい
Claude Codeは、コードベースを読んで、編集して、動作を確認するエージェントの足場(ハーネス)です。ここにUIの振る舞いまで全部自作させると、生成コードが長くなり、レビューする差分も膨らみます。フォーカス管理のような細かいロジックは、人間がレビューで見落としやすい場所でもあります。
Radixを前提にすると、Claude Codeに任せる範囲がぐっと絞れます。
- 部品の組み立て(どのプリミティブをどう配置するか)
- 既存CSSやデザイントークンへの接続
- React側の状態管理・業務ロジック
- テストすべき観点の列挙
つまり、壊れやすい振る舞いはRadixに、デザインと業務ロジックはClaude Codeにと分担できる。レビューする側も「Radixの作法どおりか」だけ見ればよくなります。
まず入れるパッケージ
Radixは今、公式が radix-ui 単一パッケージ からのimportを推奨しています。公式の言葉では「radix-uiパッケージをインストールし、必要なプリミティブをimportすることを推奨します」。
npm install radix-ui
importはこの形です。
import { Dialog, DropdownMenu, Tabs } from "radix-ui";
一方、既存のReactプロジェクトや古い記事では、@radix-ui/react-dialog のような 個別パッケージ も広く使われています。どちらでも動きますが、この記事のサンプルは個別パッケージで書きます。既存プロジェクトに足すとき、package.jsonを見れば「どの部品を入れているか」が一目で分かるからです。
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
pnpmならこうです。
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
新規プロジェクトなら単一パッケージ、既存の改修なら個別パッケージ、と覚えておけば迷いません。
Claude Codeへの頼み方
依頼文には、見た目の希望だけでなく「守ってほしい振る舞い」を書きます。「いい感じのモーダルを作って」だと、アクセシビリティと検証条件がきれいに抜け落ちます。
claude "React + TypeScriptの既存画面に、Radix UIで確認Dialog・ユーザーメニュー・設定Tabsを追加して。
条件:
- @radix-ui/react-dialog, @radix-ui/react-dropdown-menu, @radix-ui/react-tabs を使う
- DialogにはTitleとDescriptionを必ず置く。見た目に出したくないTitleはVisuallyHiddenで隠す
- 閉じるボタンにはaria-labelを付ける
- フォーカスリングを消さず、:focus-visibleで見えるようにする
- 既存の色トークンがあればそれに合わせる
- 実装後、キーボード操作・モバイル幅・型エラーの確認項目を出して"
出てきたコードは、そのまま信じずに差分を見ます。僕がいつも確認するのはこの3点です。asChildを付けたTriggerが「ボタンの中にボタン」を作っていないか。Dialog.Titleを見た目の都合でこっそり消していないか。outline: noneだけ書いてフォーカス表示を殺していないか。ここさえ押さえれば、致命的な事故はだいたい防げます。
コピペで動くReactコード
下のコードは、確認ダイアログ・ユーザードロップダウン・設定タブを1ファイルにまとめたものです。Vite、Next.jsのClient Component、ふつうのReact SPAで動きます。Next.js App Routerで使うときは、ファイル先頭に "use client"; を足してください。
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tabs from "@radix-ui/react-tabs";
import "./radix-accessible-demo.css";
type User = {
name: string;
email: string;
};
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => Promise<void> | void;
};
// 確認ダイアログ。フォーカスの閉じ込め・Escで閉じる・読み上げはRadixが担当
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
danger = false,
onConfirm,
}: ConfirmDialogProps) {
const [pending, setPending] = React.useState(false);
async function handleConfirm() {
setPending(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setPending(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="radix-overlay" />
<Dialog.Content className="radix-dialog">
{/* Title と Description は読み上げの命綱。消さない */}
<Dialog.Title className="radix-dialog-title">{title}</Dialog.Title>
<Dialog.Description className="radix-dialog-description">
{description}
</Dialog.Description>
<div className="button-row">
<Dialog.Close asChild>
<button type="button" className="button secondary">
Cancel
</button>
</Dialog.Close>
<button
type="button"
className={`button ${danger ? "danger" : "primary"}`}
onClick={handleConfirm}
disabled={pending}
>
{pending ? "Working..." : confirmLabel}
</button>
</div>
<Dialog.Close asChild>
{/* アイコンだけのボタンには aria-label で名前を付ける */}
<button type="button" className="icon-button" aria-label="Close dialog">
x
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// ユーザーメニュー。矢印キーでの項目移動やラジオ選択はRadixが処理
export function UserMenu({
user,
onOpenProfile,
onOpenBilling,
onSignOut,
}: {
user: User;
onOpenProfile: () => void;
onOpenBilling: () => void;
onSignOut: () => void;
}) {
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="user-trigger" aria-label={`${user.name} menu`}>
<span className="avatar" aria-hidden="true">
{user.name.slice(0, 1).toUpperCase()}
</span>
<span>{user.name}</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="dropdown-content" align="end" sideOffset={8}>
<DropdownMenu.Label className="dropdown-label">{user.email}</DropdownMenu.Label>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenProfile()}>
Profile
</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenBilling()}>
Billing
</DropdownMenu.Item>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Label className="dropdown-label">Theme</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={theme}
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
>
<DropdownMenu.RadioItem className="dropdown-item" value="light">
Light
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="dark">
Dark
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="system">
System
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item danger-text" onSelect={() => onSignOut()}>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
// 設定タブ。矢印キー・Home・Endでの移動はRadixが面倒を見る
export function SettingsTabs() {
return (
<Tabs.Root defaultValue="profile" className="tabs-root">
<Tabs.List className="tabs-list" aria-label="Account settings">
<Tabs.Trigger className="tabs-trigger" value="profile">
Profile
</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="security">
Security
</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="notifications">
Notifications
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tabs-content" value="profile">
<label className="field">
<span>Display name</span>
<input defaultValue="Masa" />
</label>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="security">
<p>Require two-factor authentication before changing billing settings.</p>
<button type="button" className="button secondary">
Review security
</button>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="notifications">
<label className="check-row">
<input type="checkbox" defaultChecked />
<span>Email me when a project is exported.</span>
</label>
</Tabs.Content>
</Tabs.Root>
);
}
export default function AccessibleRadixDemo() {
const [open, setOpen] = React.useState(false);
const user = { name: "Masa", email: "[email protected]" };
return (
<main className="demo-shell">
<header className="demo-toolbar">
<UserMenu
user={user}
onOpenProfile={() => console.log("profile")}
onOpenBilling={() => console.log("billing")}
onSignOut={() => console.log("sign out")}
/>
</header>
<section className="demo-panel">
<h2>Project settings</h2>
<SettingsTabs />
<button type="button" className="button danger" onClick={() => setOpen(true)}>
Delete project
</button>
</section>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Delete this project?"
description="This action cannot be undone. Export your data before deleting."
confirmLabel="Delete"
danger
onConfirm={() => console.log("delete project")}
/>
</main>
);
}
注目してほしいのは、コードのどこにも「Escを監視する処理」や「フォーカスを戻す処理」が出てこない点です。Dialog.Rootで包むだけで、ぜんぶ裏でやってくれている。これがヘッドレスの効きどころです。
スタイルは自分で書く
Radixは未スタイルなので、見た目は自分で用意します。下は最小限のCSSです。ここで手を抜くと事故るポイントが3つあります。フォーカスリングを消さないこと、モバイル幅でDialogが画面外に出ないこと、メニューのz-indexと影をサイト全体のレイヤー設計に合わせること。
.demo-shell {
min-height: 100vh;
background: #f8fafc;
color: #0f172a;
padding: 32px;
}
.demo-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 24px;
}
.demo-panel {
max-width: 720px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 24px;
}
.button,
.user-trigger,
.icon-button,
.tabs-trigger {
font: inherit;
}
.button {
border: 0;
border-radius: 6px;
cursor: pointer;
padding: 10px 14px;
font-weight: 600;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.button.primary {
background: #2563eb;
color: #ffffff;
}
.button.secondary {
background: #e2e8f0;
color: #0f172a;
}
.button.danger {
background: #dc2626;
color: #ffffff;
}
.button-row {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.radix-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
animation: overlay-show 160ms ease-out;
}
.radix-dialog {
position: fixed;
left: 50%;
top: 50%;
width: min(calc(100vw - 32px), 480px);
max-height: calc(100vh - 32px);
overflow: auto;
transform: translate(-50%, -50%);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
padding: 24px;
animation: content-show 160ms ease-out;
}
.radix-dialog-title {
margin: 0;
font-size: 1.25rem;
}
.radix-dialog-description {
margin: 8px 0 0;
color: #475569;
line-height: 1.7;
}
.icon-button {
position: absolute;
right: 12px;
top: 12px;
width: 32px;
height: 32px;
border: 0;
border-radius: 999px;
background: transparent;
cursor: pointer;
}
.user-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #ffffff;
cursor: pointer;
padding: 6px 10px;
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: #0f172a;
color: #ffffff;
font-size: 0.8rem;
font-weight: 700;
}
.dropdown-content {
min-width: 220px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
padding: 6px;
animation: menu-show 140ms ease-out;
}
.dropdown-label {
color: #64748b;
font-size: 0.85rem;
padding: 8px 10px;
}
.dropdown-separator {
height: 1px;
background: #e2e8f0;
margin: 4px;
}
.dropdown-item {
border-radius: 6px;
cursor: pointer;
outline: none;
padding: 8px 10px;
}
.dropdown-item[data-highlighted] {
background: #eff6ff;
color: #1d4ed8;
}
.danger-text {
color: #dc2626;
}
.tabs-root {
margin: 16px 0 24px;
}
.tabs-list {
display: flex;
border-bottom: 1px solid #e2e8f0;
gap: 4px;
}
.tabs-trigger {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
cursor: pointer;
padding: 10px 12px;
}
.tabs-trigger[data-state="active"] {
border-color: #2563eb;
color: #1d4ed8;
font-weight: 700;
}
.tabs-content {
padding: 16px 0;
}
.field,
.check-row {
display: grid;
gap: 8px;
}
.field input {
max-width: 320px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 10px;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 2px;
}
@keyframes overlay-show {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes content-show {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes menu-show {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 560px) {
.demo-shell {
padding: 16px;
}
.button-row {
flex-direction: column-reverse;
}
.button-row .button {
width: 100%;
}
}
data-state="active" や data-highlighted といった属性に注目してください。Radixは状態を data属性 として吐き出すので、こちらは「アクティブなタブはこう光る」とCSSで書くだけ。状態の出し入れはRadix任せ、見た目はこちら、という分担がここでも効いています。Tailwindを使うなら、この属性を data-[state=active]: のようなバリアントで拾えます。詳しい組み合わせはClaude CodeのTailwind CSS実装術で扱っています。
Radix と shadcn/ui の違い
ここがいちばん混乱しやすいので、はっきりさせます。「Radix UIとshadcn/uiって、どっち使えばいいの?」という質問は、実は前提が少しズレています。両者は競合ではなく、土台と建物の関係だからです。
| Radix UI(Primitives) | shadcn/ui | |
|---|---|---|
| 立ち位置 | 振る舞い+アクセシビリティの素の部品 | Radixを土台に味付けしたコンポーネント |
| 見た目 | なし(自分で書く) | スタイル済み(Tailwind前提) |
| 配布形態 | npmパッケージ | コードをコピペ(CLIで取り込む) |
| 向いている人 | デザインを完全に自分で握りたい | きれいな初期スタイルから始めたい |
shadcn/uiの公式は「これはコンポーネントライブラリではない。あなたがコンポーネントライブラリを作るための方法だ」と言い切っています。npmで入れる従来型ではなく、コンポーネントのソースコードそのものを自分のプロジェクトにコピーする仕組み。そしてその中身は、多くがRadix Primitivesの上に作られています。
整理すると、こうです。
- Radixだけ使う:デザインを一から自分で組みたい、依存を最小にしたい。
- shadcn/uiを使う:きれいな初期デザインを出発点に、コードを手元で自由に直したい(その下でRadixが働いている)。
部品カタログとして眺めたい人はClaude Codeでshadcn/uiを使うコツが参考になります。複数の部品をブランド全体で統一していく話はClaude Codeで作るデザインシステムへ。アクセシビリティそのものを深掘りしたいならClaude Codeのアクセシビリティ実装が近いテーマです。
使いどころ3つ
| ユースケース | Radixを使う理由 | Claude Codeに任せる作業 |
|---|---|---|
| 破壊的操作の確認Dialog | フォーカスを内側に閉じ込め、タイトルと説明を読み上げやすくする | API呼び出し、pending状態、エラー表示、テスト観点 |
| アカウントメニュー | キーボードでの項目移動、ラジオ項目、区切りを扱いやすい | ユーザー情報、ログアウト処理、計測イベント接続 |
| 設定画面のTabs | タブリスト・タブ・パネルの関係を保ちやすい | フォーム分割、URL同期、保存ボタンの配置 |
ひとつ目は、SaaSの管理画面。請求・削除・権限変更のような操作は、派手さより「誤操作を減らす」が命です。Radix Dialogで安全な土台を置き、ドメイン固有の説明文やAPI接続だけClaude Codeに任せる。
ふたつ目は、メディアや教材サイトのアカウントメニュー。プロフィール、購入履歴、学習進捗、ログアウトをまとめるとき、クリック専用の自作メニューはキーボード利用者を迷わせます。Radix Dropdown Menuなら、項目のハイライトや閉じるタイミングが揃います。
みっつ目は、設定画面。通知・セキュリティ・プロフィールを1ページにまとめるならTabsが便利です。ただし1つのタブに長いフォームを詰めすぎると、どこまで保存されるのか分からなくなる。Claude Codeには「各タブの保存責務を分ける」「未保存状態を表示する」も一緒に渡しておきます。
僕がハマった落とし穴
正直に書きます。Radixに移してからも、しばらく事故りました。
ひとつ目は、Dialog.Titleを見た目の都合で消したこと。 デザイン上タイトルを出したくない画面があって、消したら読み上げ用の名前まで消えました。正解は、公式が用意しているVisuallyHiddenという utility で「画面には出さず、読み上げには残す」やり方。@radix-ui/react-visually-hiddenでTitleを包むだけです。MDNのdialog role解説でも、ダイアログにはラベル付けとフォーカス管理が要ると説明されています。
ふたつ目は、フォーカスリングをCSSで殺したこと。 outline: noneとだけ書いたら、キーボードで操作する人が「今どこにいるか」見えなくなった。消すのではなく、:focus-visibleでデザインに合うリングへ置き換えるのが正解です。
みっつ目は、DropdownMenu.Trigger asChildの中にボタンがあるのに、さらにボタンを入れたこと。 HTMLとして不正な「ボタンの中のボタン」になり、支援技術で挙動が不安定になりました。asChildは「子要素をそのままトリガーにする」指定なので、子はボタン1個で十分。Claude Codeの差分では、生成されたDOM構造まで見ます。
よっつ目は、Dialogから別のDialogを開く流れを安易に作ったこと。 WAI-ARIAのModal Dialog Patternは、閉じたら呼び出し元へフォーカスを戻す考え方を示しています。多段Dialogを作る前に、画面を分ける・確認を1ステップにする・Undoを用意する、を先に検討すべきでした。
いつつ目は、モバイル確認をサボったこと。 Dialogの幅を600px固定にしていて、スマホで横にはみ出した。width: min(calc(100vw - 32px), 480px)のように、画面幅に追従する制約を入れておけば防げます。
よくある質問
Q. Radix UIとheadless UI、Material UIみたいなのと何が違う? A. Material UIは見た目込みの「完成料理」型。Radixは見た目なしの「下ごしらえ済み食材」型(ヘッドレス)です。Radixはデザインを自分で全部決められる代わりに、CSSは自分で書きます。
Q. RadixとshadcnはどちらをClaude Codeに使わせるべき? A. 競合しません。shadcnの中身はRadixです。デザインを一から組むならRadix単体、きれいな初期スタイルから始めたいならshadcn(その下でRadixが動く)。
Q. インストールはradix-uiと@radix-ui/react-*のどっち?
A. 公式の現在の推奨は単一パッケージのradix-ui。既存プロジェクトへ少しずつ足すなら、依存がpackage.jsonで見やすい個別パッケージも有効です。
Q. アクセシビリティはRadixを入れれば自動で完璧になる?
A. なりません。フォーカス管理やキー操作はRadixが担いますが、outline: noneで台無しにする、Titleを消す、コントラスト不足、といった自分のミスは別問題。最後はキーボードと読み上げで自分で確認します。
Q. スタイルはCSSとTailwind、どちらがいい?
A. どちらでも動きます。Radixは状態をdata属性で出すので、Tailwindならdata-[state=active]:で拾えます。既存プロジェクトの流儀に合わせるのが正解です。
実際に試した結果
検証用のReact画面で、自作モーダルをRadix Dialogに置き換え、メニューとタブもRadixへ寄せてみました。コード量は少し増えました。でも、増えたのは「見た目のCSS」だけで、Escやフォーカス戻りといった壊れやすいロジックは一行も書いていない。レビューは逆にラクになりました。
決め手は、Claude Codeに再レビューさせたときです。「フォーカスはどこに戻る?」「Titleは読み上げられる?」「モバイル幅で閉じるボタンは押せる?」と観点を渡すと、見た目だけの修正より実用的な指摘が返ってきた。最初にあった「Tabが裏に抜ける」事故は、Radixに移した時点で消えていました。
結論はシンプルです。Radix UIは、アクセシビリティを丸投げする道具ではなく、壊れやすい土台をRadixに任せ、人間とClaude Codeが見た目と仕様の確認に集中するための道具。賢いAIに「いい感じのモーダル」を期待するより、壊れない土台を先に敷く。遠回りに見えて、これがいちばん早い。
公式ドキュメントは手元に開いておくと安心です。Radix Primitives Introduction、Radix Dialog docs、WAI-ARIA Modal Dialog Patternあたりが入り口です。
導入レビューやチームでの設計をまとめて相談したいときは、研修・導入相談でもRadixやアクセシビリティ確認を扱っています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。