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>要素としてレンダリングされます。でも、リンクにツールチップを追加したい場合はどうでしょう?そこでasChildの出番です:
<Tooltip.Trigger asChild>
<a href="/help">ヘルプセンター</a>
</Tooltip.Trigger>
asChild={true}を設定すると、Radixは独自の<button>をレンダリングしません。代わりに、提供された子要素を「クローン」し、その振る舞いとプロップスを渡します。このリンクはTooltip Triggerのすべての機能を持つようになります:マウスホバーでツールチップ表示、キーボードフォーカスでもトリガー、正しいaria属性。
便利に見えます。
でも、ここに罠があります。
フォーカス不可能な要素に変更すると、アクセシビリティは消えます。
// ❌ 間違い
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">クリックして</div>
</Tooltip.Trigger>
divはキーボードフォーカスを受け取れません(手動でtabIndex={0}を追加しない限り)。Enter/Spaceキーにも応答しません。スクリーンリーダーはこれをボタンとして扱いません。キーボードユーザーはこのツールチップに「到達」できません。
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をスプレッドする必要がある
Radixはクローン時に多くのプロップスを渡します:イベントハンドラ、aria属性、ref。コンポーネントがこれらを受け取らないと、機能が壊れます。
// ❌ 間違い:propsを受け取らない
const MyButton = () => <button className="btn">...</button>
// ✅ 正しい:すべてのpropsをスプレッド
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. コンポーネントはrefを転送する必要がある
Radixは時々DOMに直接アクセスする必要があります(サイズ測定、フォーカス管理)。refがないと、エラーになります。
// ❌ 間違い:refを受け取らない
const MyButton = (props) => <button {...props}>...</button>
// ✅ 正しい:refを転送
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
正直、これらのルールはRadixだけでなく、あらゆる「リーフコンポーネント」に適用されます。すべてのpropsとrefを受け入れることは基本です。
面白い使い方:複数のRadixコンポーネントをネストできます。
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>ダイアログを開く</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
1つのボタンで、同時にTooltip TriggerとDialog Trigger。両方の振る舞いが重なり、問題なく動作します。
フォーカス管理とキーボードナビゲーション
フォーカス管理はアクセシビリティの中で最も見落とされがちな部分です。
多くの人は「見た目の良さ」に集中し、ユーザーがマウスを使わない可能性を忘れています。キーボードユーザー、スクリーンリーダーユーザー—彼らの操作は完全にフォーカス位置に依存しています。
Radixはこの点で多くの自動処理を行います。例えば:
AlertDialogが開くと、フォーカスは自動的にCancelボタンに移動します。
これは意図的な設計です。AlertDialogは通常、危険な操作(削除、終了)の確認に使用されます。開いた後、最も可能性の高いアクションは「キャンセル」であって「確認」ではありません。Cancelにフォーカスがあれば、Enterキーを1回押すだけでダイアログが閉じ、誤操作を防げます。
もしConfirmにフォーカスがあったらどうでしょう?ユーザーが誤ってEnterを押すと、削除が実行されます。災難です。
この振る舞いは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はデフォルトで最初のフォーカス可能要素を探します。入力がCancelの前にあるので、フォーカスは入力フィールドに入ります。ユーザーは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が内部で処理します。
でも、1つだけやらなければならないことがあります:コントロールにアクセシブルな名前を提供すること。
スクリーンリーダーユーザーは、このボタンが何をするのか、このダイアログが何と呼ばれているのか、この入力フィールドに何を入力するのかを知る必要があります。名前がないと、推測するしかありません。
RadixはLabelプリミティブを提供しています:
<Label.Root htmlFor="email-input">メールアドレス</Label.Root>
<Input id="email-input" />
Label.Rootは自動的に入力にリンクします。スクリーンリーダーは「メールアドレス」と読み上げてから、入力値を読み上げます。
カスタムコントロール(ネイティブ入力ではない)の場合、手動で名前を提供します。
<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(無料ダウンロード)があります。コンポーネントがどのように読み上げられるか聞いてください。「ボタン」とだけ聞こえて「注文を送信ボタン」と聞こえないなら、アクセシブルな名前が不足しています。
もう一つ:色のコントラスト。
Radixはスタイルを扱わないので、色のコントラストはあなたの責任です。WCAGは通常テキストに少なくとも4.5:1、大きなテキストに3:1のコントラスト比を要求します。shadcn/uiのデフォルトは通常合格しますが、色を変更する際は注意が必要です。
WebAIM Contrast Checkerというツールがあります。前景色と背景色を入力するとコントラストを計算してくれます。
実践チェックリスト
shadcn/uiコンポーネントをカスタマイズした後、このチェックリストで検証してください:
asChildチェック
asChildの子要素はフォーカス可能ですか?(button/a/input、divではない)- カスタムコンポーネントはすべてのpropsをスプレッドしましたか?
- カスタムコンポーネントはrefを転送しましたか?
フォーカス管理チェック
- ダイアログが開くと、フォーカスは正しい場所に行きますか?
- ダイアログが閉じると、フォーカスはトリガーに戻りますか?
- ネストされたフォーカス可能要素がある場合、フォーカス順序は論理的ですか?
キーボードナビゲーションチェック
- Tabでコンポーネントに入れますか?
- 矢印キーでオプションを切り替えられますか(Tabs、Dropdown)?
- Enterでアクションをトリガーできますか?
- Escでダイアログを閉じられますか?
- Spaceで状態を切り替えられますか(Switch、Checkbox)?
ARIAチェック
- すべてのコントロールにアクセシブルな名前がありますか?
- スクリーンリーダーは役割と状態を正しく読み上げられますか?
- 動的な状態変化に正しいaria-live領域がありますか?
視覚チェック
- フォーカスインジケーターは明確に見えますか?
- 色のコントラストは基準を満たしていますか(4.5:1または3:1)?
- 色だけでなく、情報はアイコンやテキストでも伝達されていますか?
テストツール
- キーボードテスト:マウスを切断し、キーボードだけで全フローを操作
- スクリーンリーダー:VoiceOver(Mac)またはNVDA(Windows)
- 自動化:axe DevToolsブラウザ拡張機能
結論
shadcn/uiはコードの自由を与えますが、その自由には境界があります。
その境界はRadixのアクセシビリティ振る舞いです。スタイル、レイアウト、クラス名は変更できますが、基盤の振る舞いロジックを壊してはいけません。buttonをdivに置き換えたり、propsのスプレッドを忘れたりすると、キーボードユーザーが苦しみます。
いくつかのポイントを覚えておいてください:
- asChild使用時:子要素はフォーカス可能である必要がある。カスタムコンポーネントはpropsをスプレッド+refを転送
- フォーカス管理:ダイアログコンテンツをカスタマイズする際、フォーカスがどこに行くか確認
- ARIA属性:Radixはroleを自動追加するが、ラベルは自分で提供
次回コンポーネントを変更する際、まずキーボードでテストしてください。問題を見つけたら、すぐに修正してください。QAを待たないでください。
結局のところ、アクセシビリティは「追加機能」ではなく、基本要件です。shadcn/uiとRadixは最も難しい部分をすでにやってくれています。彼らの良い仕事を壊さないようにしましょう。
FAQ
shadcn/uiとRadix UIの関係は?
アクセシビリティを壊さずにasChildを使うには?
• 子要素はフォーカス可能である必要がある(button/a/input)、divは使えない
• カスタムコンポーネントはpropsをスプレッド:`(props) => <button {...props} />`
• カスタムコンポーネントはrefを転送:`React.forwardRef((props, ref) => ...)`
ダイアログをカスタマイズする際、フォーカス管理で注意すべきことは?
コンポーネントのアクセシビリティが正しく動作しているかテストするには?
• Tabでコンポーネントに入れますか?
• 矢印キーでオプションを切り替えられますか?
• Enterでアクションをトリガーできますか?
• Escでダイアログを閉じられますか?
その後、スクリーンリーダー(MacはVoiceOver、WindowsはNVDA)を開いて一度聞いてください。
Radixがaria属性を自動追加する場合、自分でやるべきことは?
5 min read · 公開日: 2026年3月30日 · 更新日: 2026年3月30日
関連記事
Nginx リバースプロキシ完全ガイド:upstream、バッファ、タイムアウト
Nginx リバースプロキシ完全ガイド:upstream、バッファ、タイムアウト
Tailwind パフォーマンス最適化:JIT、content設定、本番バンドルサイズ管理
Tailwind パフォーマンス最適化:JIT、content設定、本番バンドルサイズ管理
Dialog、Sheet、Popover:オーバーレイコンポーネントのアクセシビリティとフォーカス管理

コメント
GitHubアカウントでログインしてコメントできます