言語を切り替える
テーマを切り替える

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 がかなり手間を省いてくれます。

正しい rolearia-* 属性を自動でコンポーネントに付けてくれます。たとえば、

  • 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 のアクセシビリティの振る舞いです。スタイルを変え、レイアウトを変え、クラス名を変えるのはかまいません。でも、土台の振る舞いのロジックを壊してはいけません。buttondiv に差し替えたり、props の spread を忘れたりした瞬間、キーボードユーザーが割を食うのです。

いくつかの要点を覚えておきましょう。

  • asChild のとき:子要素はフォーカス可能でなければならず、カスタムコンポーネントは props を spread し ref を forward する
  • フォーカス管理:ダイアログの内容をカスタマイズするときは、フォーカスがどこへ行くか確認する
  • ARIA 属性:Radix が role を自動で付けるが、label は自分で提供する

次にコンポーネントを変えるときは、まずキーボードで一度操作してみてください。問題を見つけたら修正する。QA に指摘されるのを待たないことです。

突き詰めると、アクセシビリティは「おまけの機能」ではなく、基本的な要件です。shadcn/ui と Radix は最も難しい部分をすでにやってくれています。あとは、その厚意を台無しにしないことだけです。

FAQ

shadcn/ui と Radix UI はどういう関係ですか?
shadcn/ui は「コード配布プラットフォーム」で、ソースコードをプロジェクトにコピーして使います。アクセシビリティ能力は土台の Radix Primitives 由来で、aria 属性・フォーカス管理・キーボードナビゲーションを担当します。shadcn/ui は Tailwind のスタイルだけを担います。
asChild 属性はどう使えばアクセシビリティを壊しませんか?
ポイントは 3 つ。子要素はフォーカス可能な要素(button/a/input)にし、div は使わない。カスタムコンポーネントは必ず props を spread する、つまり props =&gt; &lt;button {...props} /&gt;。そしてカスタムコンポーネントは必ず ref を forward する。
ダイアログの内容をカスタマイズするとき、フォーカス管理で気をつけることは?
AlertDialog はデフォルトでフォーカスを Cancel ボタンに置きます。入力欄などフォーカス可能な要素を追加すると、フォーカスが意図しない場所へ行くことがあります。autoFocus でフォーカス先を指定するか、要素の順序を調整しましょう。
コンポーネントのアクセシビリティが正常か、どうテストすればいい?
一番簡単な方法は、マウスを脇に置いてキーボードだけで全フローを操作することです。Tab でコンポーネントに入れますか? 矢印キーで選択肢を切り替えられますか? Enter で操作を実行できますか? Esc でダイアログを閉じられますか? そのあとスクリーンリーダー(Mac は VoiceOver、Windows は NVDA)で一度聞いてみましょう。
Radix が aria 属性を自動で付けてくれるなら、あと何をすればいい?
コントロールに accessible name を提供する必要があります。スクリーンリーダーの利用者は、このボタンが何か、このダイアログが何という名前かを知る必要があります。aria-label または aria-labelledby で可視テキストと関連付けましょう。

4分で読めます · 公開日: 2026年3月30日 · 更新日: 2026年6月8日

コメント

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