shadcn/ui と Radix:カスタマイズしてもアクセシビリティを保つ方法
先週、同僚にこう聞かれました。「このボタン、キーボードだとどうやって押すの?」
一瞬、戸惑いました。うちは shadcn/ui を使っているのに、なぜこんな問題が起きるのか。DevTools を開いて見てみると、彼が Tooltip.Trigger の外側を <div> で包んでいたのです——カスタムスタイルを当てるために。問題はここにありました。
正直、私も似たような落とし穴にはまったことがあります。shadcn/ui を使い始めたころは、これらのコンポーネントは「自由に変えていい」と思い込んでいました。コードはプロジェクトにコピーされているのですから。スタイルをいじり、タグを差し替え、ラッパーを足す。見た目には問題なさそうでした。ところがある日、QA テストでこう発覚します。キーボード操作が効かない、スクリーンリーダーが内容を読み上げない、インタラクションのフロー全体が途切れている、と。
そこでようやく気づきました。shadcn/ui の「自由」には代償があるのだと。ソースコードは渡してくれますが、その裏には Radix のアクセシビリティの魔法が隠れています。むやみに変えると、この魔法が壊れるのです。
この記事では、shadcn/ui と Radix の関係を整理し、コンポーネントをカスタマイズするときにアクセシビリティをどう保つかを中心に解説します。読み終えるころには、asChild の使い方、フォーカス管理のやり方、ARIA 属性の継承の仕組みが、だいたいつかめるはずです。少なくとも次にコンポーネントを変えるとき、どこは触れて、どこは触れてはいけないかが分かるようになります。
shadcn/ui と Radix:結局どういう関係なのか?
まず、多くの人が分かっていないことから話します。shadcn/ui は npm パッケージではありません。
npm install @shadcn/ui でインストールすることはできません。これは本質的に「コード配布プラットフォーム」です。コンポーネントのソースコードを渡してくれて、それをプロジェクトにコピーすると、以降そのコードは完全にあなたのものになります。変えたければ変える、消したければ消す、誰も止めません。
では、これらのコンポーネントのアクセシビリティ機能はどこから来るのか。Radix です。
Radix UI は「スタイルを持たないコンポーネントライブラリ」で、Primitives とも呼ばれます。スタイルは提供せず、振る舞いだけを提供します。たとえば Dialog を開いたときのフォーカス管理、Dropdown Menu のキーボード上下キーの処理、Tooltip をスクリーンリーダーでどう隠すか、といったものです。これらはすべて WAI-ARIA 仕様に準拠し、NVDA、JAWS、VoiceOver といった主要なスクリーンリーダーで実機検証もされています。
shadcn/ui は、その Radix の上に Tailwind CSS のスタイルを一枚かぶせたものです。見栄えのよい外観を渡しつつ、Radix のアクセシビリティの振る舞いを下に隠しています。ボタンのコードをコピーすると、見た目はほんの数行の Tailwind クラス名ですが、中には実は Radix のロジックが包まれているのです。
もっとはっきり言いましょう。
- Radix は「使える」を担当:aria 属性、role、フォーカス管理、キーボードナビゲーション
- shadcn/ui は「見栄え」を担当:Tailwind のスタイル、デザインの一貫性
ですから shadcn/ui のコンポーネントを変えるときは、こう覚えておいてください。あなたが変えているのは「表層」であり、「土台」の振る舞いのロジックは Radix 由来だ、と。表層は自由に変えていい。でも土台を間違えて変えると、事故になります。
asChild 属性:魔法か、それとも罠か?
asChild は Radix の中でもとても特別な属性です。ほとんどの Radix コンポーネントは、その各「パーツ」でこの属性をサポートしています。
どういう意味でしょうか。たとえば Tooltip.Trigger はデフォルトで <button> 要素としてレンダリングされます。でも Tooltip をリンクに付けたいこともあります。そんなときに asChild を使います。
<Tooltip.Trigger asChild>
<a href="/help">ヘルプセンター</a>
</Tooltip.Trigger>
asChild={true} を設定すると、Radix は自前の <button> をレンダリングせず、あなたが渡した子要素を「クローン」し、その振る舞いと属性を引き渡します。このリンクは Tooltip Trigger のすべての機能を手に入れます。マウスホバーで Tooltip を表示し、キーボードフォーカスでも発火し、正しい aria 属性を持つ、というわけです。
便利そうに見えます。
でも罠もここにあります。
フォーカスできない要素に差し替えると、アクセシビリティが丸ごと失われます。
// ❌ 誤った例
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">クリックしてね</div>
</Tooltip.Trigger>
div はキーボードでフォーカスできず(tabIndex={0} を手動で付けない限り)、Enter/Space キーにも反応しません。スクリーンリーダーはこれをボタンとは認識しません。キーボードで操作するユーザーは、この Tooltip に「触れる」ことすらできないのです。
Radix の公式ドキュメントにもはっきり書かれています。「If you were to switch it to a div, it would no longer be accessible.」
とはいえ、たいていの場合は直接 div に差し替えることはしません。もっとよくあるのは、自分の React コンポーネントを使うケースです。
<Tooltip.Trigger asChild>
<MyButton>クリックしてね</MyButton>
</Tooltip.Trigger>
これは問題ありません。ただし、必ず守るべきルールが 2 つあります。
1. コンポーネントは必ず props を spread する
Radix は子要素をクローンするとき、たくさんの属性を渡します。イベントハンドラー、aria 属性、ref などです。あなたのコンポーネントがこれらの属性を受け取らないと、機能が途切れます。
// ❌ 誤り:props を受け取らない
const MyButton = () => <button className="btn">...</button>
// ✅ 正しい:すべての props を spread する
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. コンポーネントは必ず ref を forward する
Radix は DOM 要素に直接アクセスする必要がある場合があります(サイズの測定やフォーカス管理など)。ref を渡さないとエラーになります。
// ❌ 誤り:ref を受け取らない
const MyButton = (props) => <button {...props}>...</button>
// ✅ 正しい:ref を forward する
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
正直に言うと、この 2 つのルールは Radix のためだけではありません。どんな「リーフコンポーネント」を書くときにもそうすべきです。すべての props と ref を受け取るのは、基本的なたしなみなのです。
もうひとつ面白い使い方があります。複数の Radix コンポーネントを入れ子にできるのです。
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>ダイアログを開く</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
ひとつのボタンが、同時に Tooltip Trigger でもあり Dialog Trigger でもあります。2 つの振る舞いが重なっても、問題なく動きます。
フォーカス管理とキーボードナビゲーション
フォーカス管理は、アクセシビリティの中でも最も見落とされやすい部分です。
多くの人は「見た目をよくする」ことばかり考えて、ユーザーがマウスを使わないかもしれないことを忘れがちです。キーボードユーザーやスクリーンリーダーユーザーの操作は、完全にフォーカス位置に依存しています。
Radix はこの点で多くの自動処理をしてくれます。一例を挙げましょう。
AlertDialog を開くと、フォーカスが自動で Cancel ボタンに移ります。
これは丁寧に設計されたディテールです。AlertDialog はたいてい危険な操作(削除、ログアウト)の確認に使われます。ユーザーがダイアログを開いたとき、最もありうる動作は「確認」ではなく「キャンセル」です。フォーカスを Cancel に置けば、ユーザーは Enter を一度押すだけでダイアログを閉じられ、誤操作を避けられます。
もしフォーカスが Confirm ボタンに止まっていたら? ユーザーがうっかり Enter を押し、そのまま削除が実行されてしまいます。大惨事です。
この振る舞いは Radix が WAI-ARIA authoring practices に従って実装したものです。自分でコードを書く必要はありません。
でもここで問題が出てきます。AlertDialog の内容をカスタマイズすると、フォーカスが意図しない場所へ行くことがあるのです。
たとえばダイアログの中に入力欄を追加したとします。
<AlertDialog.Content>
<AlertDialog.Title>削除を確認しますか?</AlertDialog.Title>
<AlertDialog.Description>「DELETE」と入力して確認してください</AlertDialog.Description>
<input placeholder="DELETE と入力" /> {/* あなたが追加した要素 */}
<AlertDialog.Cancel>キャンセル</AlertDialog.Cancel>
<AlertDialog.Action>確認</AlertDialog.Action>
</AlertDialog.Content>
さて、ダイアログを開くと、フォーカスはどこへ行くでしょう?
Radix はデフォルトで最初のフォーカス可能な要素を探します。あなたの input は Cancel の前にあるので、フォーカスは input に入ります。ユーザーは Cancel にたどり着くまで何度か Tab を押さねばなりません。これでは期待されたフローが途切れてしまいます。
解決策は、autoFocus でフォーカス先を指定するか、要素の順序を調整することです。
<AlertDialog.Content>
<AlertDialog.Title>削除を確認しますか?</AlertDialog.Title>
<AlertDialog.Description>「DELETE」と入力して確認してください</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>キャンセル</AlertDialog.Cancel> {/* フォーカスを強制 */}
<input placeholder="DELETE と入力" />
<AlertDialog.Action>確認</AlertDialog.Action>
</AlertDialog.Content>
Cancel を前に移動するか、autoFocus を付けます。こうすればフォーカスが迷子になりません。
キーボードナビゲーションにも似た問題があります。
Tabs コンポーネント:ユーザーは左右の矢印キーでタブを切り替えます。これは WAI-ARIA の標準的な振る舞いです。タブにカスタムスタイルを当てたとき、うっかり role="tab" を上書きしてしまうと、キーボードナビゲーションが効かなくなります。
Dropdown Menu:上下の矢印キーでメニュー項目を選び、Enter で確定、Esc で閉じます。これらはすべて Radix の内部で処理されています。でも、メニュー項目に onSelect ではなく onClick を付けると、キーボードの振る舞いを壊してしまうかもしれません。
テスト方法は単純明快です。マウスを脇に置いて、キーボードだけでコンポーネントの全フローを操作するのです。
- Tab でコンポーネントに入れますか?
- 矢印キーで選択肢を切り替えられますか?
- Enter で操作を実行できますか?
- Esc でダイアログを閉じられますか?
どこか一か所でも引っかかるなら、アクセシビリティに問題があるということです。
ARIA 属性の自動継承
ARIA 属性については、Radix がかなり手間を省いてくれます。
正しい role や aria-* 属性を自動でコンポーネントに付けてくれます。たとえば、
- Dialog には
role="dialog"とaria-modal="true"が付く - Tabs.Tab には
role="tab"とaria-selectedが付く - Switch には
role="switch"とaria-checkedが付く
これらはすべて自分で気にする必要はありません。Radix が内部で処理してくれます。
でも、ひとつだけ必ずやるべきことがあります。コントロールに accessible name を提供することです。
スクリーンリーダーの利用者は、このボタンが何か、このダイアログが何という名前か、この入力欄に何を入れるかを知る必要があります。名前がなければ、推測するしかありません。
Radix は Label primitive を用意して手助けしてくれます。
<Label.Root htmlFor="email-input">メールアドレス</Label.Root>
<Input id="email-input" />
この Label.Root は input に自動で関連付けられ、スクリーンリーダーは読み上げるときにまず「メールアドレス」と言い、それから入力欄の値を読みます。
カスタムコントロール(ネイティブの input ではないもの)の場合は、名前を手動で提供する必要があります。
<Switch aria-label="ナイトモードを有効化" />
<Tabs.Tab aria-label="製品詳細" />
あるいは aria-labelledby で可視テキストと関連付けます。
<div id="mode-label">ナイトモード</div>
<Switch aria-labelledby="mode-label" />
検証方法:スクリーンリーダーを開いて、一度操作してみましょう。
Mac には VoiceOver があり(Cmd+F5 で起動)、Windows には NVDA があります(無料でダウンロードできます)。あなたのコンポーネントをどう読み上げるか聞いてみてください。読み上げが「注文を送信ボタン」ではなく「ボタン」だけなら、accessible name が足りないということです。
もうひとつ。色のコントラスト比です。
Radix はスタイルに関与しないので、色のコントラスト比はあなたの責任です。WCAG は、テキストと背景のコントラスト比を少なくとも 4.5:1(通常テキスト)または 3:1(大きいテキスト)にするよう求めています。shadcn/ui のデフォルト配色はたいてい基準を満たしていますが、自分で色を変えるときは注意が必要です。
WebAIM Contrast Checker というツールがあり、前景色と背景色を入力するとコントラスト比を計算してくれます。
実践チェックリスト
shadcn/ui のコンポーネントをカスタマイズするたびに、このリストで検証しましょう。
asChild チェック
asChildの子要素はフォーカス可能な要素ですか?(button/a/input であり、div ではない)- カスタムコンポーネントはすべての props を spread していますか?
- カスタムコンポーネントは ref を forward していますか?
フォーカス管理チェック
- ダイアログを開いたとき、フォーカスは正しい位置に行きますか?
- ダイアログを閉じたあと、フォーカスはトリガー要素に戻りますか?
- 入れ子のフォーカス可能な要素があるとき、フォーカスの順序は妥当ですか?
キーボードナビゲーションチェック
- Tab でコンポーネントに入れますか?
- 矢印キーで選択肢を切り替えられますか(Tabs、Dropdown)?
- Enter で操作を実行できますか?
- Esc でダイアログを閉じられますか?
- Space で状態を切り替えられますか(Switch、Checkbox)?
ARIA チェック
- すべてのコントロールに accessible name がありますか?
- スクリーンリーダーは role と状態を正しく読み上げますか?
- 動的な状態変化に正しい aria-live 領域がありますか?
ビジュアルチェック
- フォーカスインジケーターははっきり見えますか?
- 色のコントラスト比は基準を満たしていますか(4.5:1 または 3:1)?
- 色だけで情報を伝えていませんか(アイコンや文字でも補助している)?
テストツール
- キーボードテスト:マウスを抜いて、キーボードだけで全フローを操作する
- スクリーンリーダー:VoiceOver(Mac)または NVDA(Windows)
- 自動化:axe DevTools ブラウザー拡張
まとめ
shadcn/ui はコードの自由をくれますが、その自由には境界があります。
境界とは、Radix のアクセシビリティの振る舞いです。スタイルを変え、レイアウトを変え、クラス名を変えるのはかまいません。でも、土台の振る舞いのロジックを壊してはいけません。button を div に差し替えたり、props の spread を忘れたりした瞬間、キーボードユーザーが割を食うのです。
いくつかの要点を覚えておきましょう。
- asChild のとき:子要素はフォーカス可能でなければならず、カスタムコンポーネントは props を spread し ref を forward する
- フォーカス管理:ダイアログの内容をカスタマイズするときは、フォーカスがどこへ行くか確認する
- ARIA 属性:Radix が role を自動で付けるが、label は自分で提供する
次にコンポーネントを変えるときは、まずキーボードで一度操作してみてください。問題を見つけたら修正する。QA に指摘されるのを待たないことです。
突き詰めると、アクセシビリティは「おまけの機能」ではなく、基本的な要件です。shadcn/ui と Radix は最も難しい部分をすでにやってくれています。あとは、その厚意を台無しにしないことだけです。
FAQ
shadcn/ui と Radix UI はどういう関係ですか?
asChild 属性はどう使えばアクセシビリティを壊しませんか?
ダイアログの内容をカスタマイズするとき、フォーカス管理で気をつけることは?
コンポーネントのアクセシビリティが正常か、どうテストすればいい?
Radix が aria 属性を自動で付けてくれるなら、あと何をすればいい?
4分で読めます · 公開日: 2026年3月30日 · 更新日: 2026年6月8日
関連記事
セルフホスト Dev Sandbox:Docker と Go でプレビュー環境を作る
セルフホスト Dev Sandbox:Docker と Go でプレビュー環境を作る
Cloudflare Pro か Business か?3 つの軸で判断するアップグレード判断ツリー
Cloudflare Pro か Business か?3 つの軸で判断するアップグレード判断ツリー
社内ネットワーク Docker pull タイムアウトのトラブルシューティング:DNS・プロキシ・ミラー加速の完全ガイド
コメント
GitHubアカウントでログインしてコメントできます