Claude CodeとRadix UIでアクセシブルなReact UIを作る
Claude CodeとRadix UIでDialog、Dropdown、Tabsを安全に実装する手順。公式仕様、コード、落とし穴まで解説。
Reactでモーダル、ドロップダウン、タブを自作すると、見た目よりも先にキーボード操作、フォーカス移動、スクリーンリーダーへの名前付けでつまずきます。クリックだけなら動くUIでも、Tabで抜け出せない、Escapeで閉じない、開いたあとに読み上げられない、という失敗はかなり起きます。
そこで使いたいのがRadix UIです。Radix UIはスタイルを持たない低レベルのUIプリミティブで、見た目は自分で作りながら、Dialog、Dropdown Menu、Tabsなどの振る舞いを借りられます。Claude Codeに任せるときも「ゼロからモーダルを作って」ではなく、「Radix UIを使って、アクセシビリティを壊さず、既存デザインに合わせて」と指定した方が、レビューしやすい差分になります。
この記事では、初心者がClaude CodeとRadix UIでアクセシブルなReact UIを作る流れを、インストール、プロンプト、コピーして動かせるTypeScript例、スタイリング、ユースケース、落とし穴までまとめます。shadcn/uiを使う場合はClaude Code shadcn/ui活用ガイドも近いテーマです。アクセシビリティ全体の考え方はClaude Codeアクセシビリティ実装ガイドと合わせて読むと整理しやすくなります。
Radix UIを使う理由
Radix UIの公式ドキュメントでは、Radix Primitivesをアクセシビリティ、カスタマイズ性、開発体験を重視した低レベルUIコンポーネントライブラリと説明しています。重要なのは「完成済みデザインを貼るライブラリ」ではなく、「UIの振る舞いの土台を借りるライブラリ」だという点です。
たとえばDialogでは、Radix側がモーダル時のフォーカストラップ、Escapeでのクローズ、TitleとDescriptionによる読み上げ補助を提供します。TabsではWAI-ARIAのタブパターンに沿って、矢印キー、Home、Endの操作を扱います。Dropdown Menuではメニュー項目、区切り、ラジオ項目、サブメニューなどを組み合わせられます。
Claude Codeにとっても、この分担は相性がよいです。Claude Codeはコードベースを読み、編集し、検証するエージェントの足場です。UIの振る舞いまで全部自作させると、生成されたコードが長くなり、レビュー対象も増えます。Radix UIを前提にすると、Claude Codeには「部品の組み立て」「既存CSSへの接続」「状態管理」「テスト観点の列挙」に集中させられます。
flowchart LR
A["Claude Codeに要件を渡す"] --> B["Radix UIで振る舞いを実装"]
B --> C["React状態と業務ロジックを接続"]
C --> D["CSSで見た目を調整"]
D --> E["キーボード、読み上げ、モバイルを確認"]
まず入れるパッケージ
Radix UIは、公式の新しい案内ではradix-uiパッケージから必要なプリミティブをimportする方法も紹介されています。一方、既存の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自体がまだ入っていない場合は、公式ドキュメントでNode.js要件やインストール方法を確認してください。npm版は次の形です。
npm install -g @anthropic-ai/claude-code
ただし、公式ドキュメントではsudo npm install -gを避ける案内もあります。社用PCやCIで導入する場合は、権限、アップデート方法、バイナリ検証をチームで決めてから入れます。
Claude Codeへの依頼文
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を必ず置く
- 閉じるボタンにはaria-labelを付ける
- フォーカスリングを消さず、:focus-visibleで見えるようにする
- 既存の色トークンがあればそれに合わせる
- 実装後にキーボード操作、モバイル幅、コードフェンス、型エラーの確認項目を出す"
依頼後は、Claude Codeの出力をそのまま信じず、差分を見ます。asChildを使ったTriggerがbuttonの中にbuttonを作っていないか、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;
};
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">
<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>
<button type="button" className="icon-button" aria-label="Close dialog">
x
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
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>
);
}
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: "masa@example.com" };
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>
);
}
CSSで見た目を整える
Radix UIは未スタイルなので、見た目は自分で用意します。下は最小限のCSSです。大事なのは、フォーカスリングを消さないこと、モバイル幅で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%;
}
}
使いどころ3つ
| ユースケース | Radix UIを使う理由 | Claude Codeに任せる作業 |
|---|---|---|
| 破壊的操作の確認Dialog | フォーカスをDialog内に閉じ込め、タイトルと説明を読み上げやすくする | 既存API呼び出し、pending状態、エラー表示、テスト観点の追加 |
| アカウントメニュー | キーボードで項目移動、ラジオ項目、区切りを扱いやすい | ユーザー情報、ログアウト処理、計測イベントの接続 |
| 設定画面のTabs | タブリスト、タブ、タブパネルの関係を保ちやすい | フォーム分割、URL同期、保存ボタンの配置 |
1つ目はSaaS管理画面です。請求、削除、権限変更のような操作は、見た目の派手さよりも「誤操作を減らす」ことが重要です。Radix Dialogなら、まず安全な土台を置き、Claude Codeにはドメイン固有の説明文やAPI接続を任せられます。
2つ目はメディアサイトや教材サイトのアカウントメニューです。プロフィール、購入履歴、学習進捗、ログアウトをドロップダウンにまとめるとき、クリック専用のメニューを自作するとキーボード利用者が迷います。Radix Dropdown Menuを使うと、項目のハイライトや閉じるタイミングを揃えやすくなります。
3つ目は設定画面です。通知、セキュリティ、プロフィールを1ページにまとめる場合、Tabsは便利です。ただし、タブの中に長いフォームを詰め込みすぎると保存範囲が分かりにくくなります。Claude Codeには「各タブの保存責務を分ける」「未保存状態を表示する」といった条件も一緒に渡します。
よくある落とし穴
最初の落とし穴は、Dialog.Titleを見た目の都合で削ることです。タイトルが画面上に不要な場合でも、RadixのVisuallyHiddenなどで読み上げ用の名前を残します。MDNのdialog role解説でも、dialogにはラベル付けとフォーカス管理が必要だと説明されています。
次の落とし穴は、フォーカスリングをCSSで消してしまうことです。outline: noneだけを書くと、キーボード利用者は今どこにいるか分からなくなります。消すのではなく、:focus-visibleでデザインに合うリングへ置き換えます。
3つ目は、DropdownMenu.Trigger asChildの中に既にボタンがあるのに、さらにボタンを入れてしまうことです。HTMLとして不正な入れ子になり、ブラウザや支援技術で挙動が不安定になります。Claude Codeの差分では、生成されたDOM構造も確認します。
4つ目は、Dialogから別のDialogを開く複雑な流れを安易に作ることです。WAI-ARIAのModal Dialog Patternでは、閉じたときに呼び出し元へフォーカスを戻す考え方が示されています。多段Dialogを作る前に、画面を分ける、確認を1ステップにする、Undoを用意するなどを検討します。
5つ目は、モバイル確認を省くことです。Dialogの幅が600px固定、メニューが右端からはみ出す、Tabsのラベルが折れてタップしづらい、という失敗は公開後に見つかりがちです。width: min(calc(100vw - 32px), 480px)のように、画面幅に追従する制約を入れます。
公式リンクと確認観点
実装前後で見るべき公式リンクは次の通りです。
- Radix Primitives Introduction
- Radix Dialog docs
- Radix Dropdown Menu docs
- Radix Tabs docs
- WAI-ARIA Modal Dialog Pattern
- MDN dialog role
- Claude Code getting started
確認は最低でも、マウス、キーボード、スクリーンリーダー想定、モバイル幅の4方向で行います。TabでDialog内を回れるか、Escapeで閉じるか、閉じたあとに開いたボタンへ戻るか、Dropdownの項目が矢印キーで移動できるか、Tabsが矢印キーで切り替わるかを見ます。
収益化CTAを置くなら
Radix UIの記事は、単なるライブラリ紹介で終わらせるより、実装レビューや教材導線につなげやすいテーマです。読者は「アクセシブルなUIを作りたい」だけでなく、「既存プロジェクトに入れて壊さない方法」を探しています。
ClaudeCodeLabでは、Claude Codeの導入、CLAUDE.md、レビュー観点、アクセシビリティ確認、コンポーネント設計を研修・導入相談で扱えます。個人開発なら無料チートシートやテンプレートへ、チームなら実リポジトリを使ったレビュー会へつなげると自然です。CTAは記事末尾だけでなく、コード例の後と落とし穴の後にも置くと、読者の不安に合います。
この記事で紹介した内容を実際に試した結果
Masaの検証用React画面で、手書きモーダルをRadix Dialogに置き換え、メニューとタブもRadixへ寄せたところ、コード量は少し増えましたがレビュー観点はかなり明確になりました。特に「フォーカスをどう戻すか」「タイトルが読み上げられるか」「モバイル幅で閉じるボタンが押せるか」をClaude Codeに再レビューさせると、見た目だけの修正よりも実用的な指摘が返ってきました。結論として、Radix UIは初心者がアクセシビリティを丸投げする道具ではなく、Claude Codeに安全な土台を渡して、人間が仕様と見た目を確認しやすくするための道具です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。